flextool 4.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 (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. flextool-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1095 @@
1
+ import os
2
+ # glibc malloc arena cap — set BEFORE any C-extension import that
3
+ # allocates via malloc. glibc defaults to up to 8 × ncores arenas
4
+ # (≈256 on a 32-core workstation); each arena holds its own
5
+ # freed-but-not-returned-to-OS pages, so worst-case fragmentation
6
+ # scales with core count. Capping to 4 is a precautionary middle
7
+ # ground: ~64× reduction vs the default, while still allowing up to
8
+ # four concurrent allocators (HiGHS parallel presolve, Benders
9
+ # subproblem runs) without serialising every malloc through one heap.
10
+ # No measured benefit on the FlexTool cascade workload as of this
11
+ # writing — FlexTool's hot path is essentially single-threaded
12
+ # (polars pinned to 1 thread, --highs-threads typically 1). Kept
13
+ # only as a cheap precaution against future workloads where arena
14
+ # growth could matter. ``setdefault`` so the shell wins.
15
+ os.environ.setdefault("MALLOC_ARENA_MAX", "4")
16
+
17
+ import argparse
18
+ import sys
19
+ import logging
20
+ import math
21
+ import shutil
22
+ import traceback
23
+ from pathlib import Path
24
+ from datetime import datetime
25
+ import time
26
+
27
+ # Leave breadcrumbs if a compiled extension (polars / HiGHS / numpy) crashes
28
+ # natively. faulthandler can't stop a segfault, but it dumps the Python
29
+ # traceback of every thread to stderr at fault time — so a crash during, say,
30
+ # the first polars op prints the offending frame instead of nothing. The GUI
31
+ # captures this child's stderr into the job log. See flextool.env_check for
32
+ # the out-of-process probe + auto-remediation that prevents the crash.
33
+ import faulthandler
34
+ try:
35
+ faulthandler.enable()
36
+ except (AttributeError, ValueError, OSError):
37
+ # stderr may be unavailable (e.g. detached / redirected to a closed fd).
38
+ pass
39
+ from flextool._mem_sampler import start_mem_sampler
40
+ from flextool.process_outputs.write_outputs import write_outputs
41
+ from flextool.cli._console import run_tool
42
+ from flextool.cli._timing import TimingRecorder
43
+ from flextool.common_utils.precision import resolve_precision_digits
44
+ from flextool.update_flextool.ensure_settings_db import ensure_settings_db
45
+ from spinedb_api.filters.tools import name_from_dict
46
+ from spinedb_api import DatabaseMapping, to_database, DateTime
47
+ from spinedb_api.exception import NothingToCommit
48
+
49
+ # Start the memory sampler as the first statement after imports. The
50
+ # few hundred ms of import-cascade RSS that precede this point are not
51
+ # captured; the workload-level RSS curve (what the sampler exists to
52
+ # measure) is fully captured. Gated by FLEXTOOL_MEM_SAMPLER=1; no-op
53
+ # when the env var is unset.
54
+ start_mem_sampler()
55
+
56
+ class FlushingStream:
57
+ def __init__(self, stream):
58
+ self.stream = stream
59
+
60
+ def write(self, data):
61
+ self.stream.write(data)
62
+ self.stream.flush()
63
+
64
+ def __getattr__(self, attr):
65
+ return getattr(self.stream, attr)
66
+
67
+
68
+ sys.stdout = FlushingStream(sys.stdout)
69
+
70
+ #return_codes
71
+ #0 : Success
72
+ #-1: Failure (Defined in the Toolbox)
73
+ #1: Infeasible or unbounded problem (not implemented in the toolbox, functionally same as -1. For a possiblity of a graphical depiction)
74
+
75
+
76
+ def _run_solve(args, scenario_name, work_folder, timing_recorder):
77
+ """Δ.21 — drive the native polar_high cascade.
78
+
79
+ Returns a tuple ``(return_code, last_step)`` where ``last_step``
80
+ is the :class:`flextool.engine_polars.OrchestrationStep` of the
81
+ final (or only) sub-solve, used by the caller to thread
82
+ ``flex_data`` + ``solution`` into ``write_outputs`` for the
83
+ in-memory parameter / set namespace path (Δ.31).
84
+ """
85
+ if scenario_name:
86
+ timing_recorder.set_scenario(scenario_name)
87
+
88
+ # ``--csv-dump`` is a one-way debug snapshot from the live
89
+ # FlexDataProvider: when on, the cascade dumps both
90
+ # ``flex_data.dump_csvs(work_folder)`` (per-solve) and the Provider's
91
+ # captured derived frames (post-cascade snapshot below). When off
92
+ # the cascade runs purely in-memory — no CSVs hit
93
+ # ``solve_data/`` from the writer-port modules.
94
+ csv_dump_on = bool(getattr(args, 'csv_dump', False))
95
+
96
+ from flextool.engine_polars import run_chain_from_db
97
+
98
+ # Drive the native cascade end-to-end. ``run_chain_from_db``
99
+ # handles flextool's preprocessing (write_input) AND the per-solve
100
+ # LP build+solve+handoff loop in-process.
101
+ t_solve_start = time.perf_counter()
102
+ steps = run_chain_from_db(
103
+ args.input_db_url,
104
+ scenario_name,
105
+ work_folder=work_folder,
106
+ csv_dump=csv_dump_on,
107
+ )
108
+
109
+ # ``--csv-dump``: snapshot the last sub-solve's Provider to disk.
110
+ # The Provider holds every derived frame the cascade's writers
111
+ # produced; ``snapshot_processed_inputs`` writes them under
112
+ # ``work_folder`` mirroring the cascade's parent-qualified key
113
+ # layout. This is a debug oracle; the cascade itself reads only
114
+ # from the in-memory Provider.
115
+ if csv_dump_on and steps:
116
+ last_step = next(reversed(list(steps.values())))
117
+ provider = getattr(last_step, "flex_data_provider", None)
118
+ if provider is not None:
119
+ try:
120
+ provider.snapshot_processed_inputs(work_folder)
121
+ except Exception as exc: # noqa: BLE001
122
+ logging.warning(
123
+ "--csv-dump: snapshot_processed_inputs failed: %s", exc,
124
+ )
125
+
126
+ all_solves_seconds = time.perf_counter() - t_solve_start
127
+ print("--- All Flextool solves time %.4s seconds ---" % all_solves_seconds)
128
+ timing_recorder.record('all_solves', seconds=all_solves_seconds,
129
+ t_start=t_solve_start)
130
+
131
+ if not steps:
132
+ logging.error("Native cascade produced no solve steps; aborting.")
133
+ return 1, None
134
+ return _scan_cascade_optimality(steps)
135
+
136
+
137
+ def _scan_cascade_optimality(steps):
138
+ """Classify the cascade outcome into a ``(exit_code, last_step)`` pair.
139
+
140
+ Scans sub-solves for a non-optimal outcome. Two distinct cases:
141
+
142
+ * A solve accepted as *near-optimal* by
143
+ :func:`classify_acceptance` — a crossover-off interior-point solve
144
+ HiGHS would not certify ``kOptimal`` but whose primal is feasible with
145
+ a small primal--dual gap — already wrote a usable solution and returned
146
+ success from the per-solve driver. Its strict ``optimal`` mirror is
147
+ ``False`` (HiGHS status Unknown), so it is recognised here via
148
+ ``step.near_optimal`` and must NOT be treated as a failure.
149
+
150
+ * A Benders-decomposed solve that found a FEASIBLE incumbent but did not
151
+ close the optimality gap to tolerance within its iteration cap is NOT
152
+ infeasible — every subproblem/master LP solved to optimality and the
153
+ written outputs are a valid feasible plan, just not certified optimal.
154
+ Surface a loud warning (with the actual gap vs the required tolerance)
155
+ and let the run SUCCEED (exit 0) so the parquet results are consumed
156
+ downstream.
157
+
158
+ * Anything else non-optimal (a Benders solve with no feasible incumbent
159
+ at all, or a step whose solution the cascade never accepted) is a
160
+ genuine failure → exit 1. A monolithic LP that HiGHS genuinely could
161
+ not solve is rejected earlier at the solve site (``classify_acceptance``
162
+ → ``FlexToolSolveError``, which names the precise cause) and never
163
+ reaches this scan; if one does, it is reported here with the facts the
164
+ slim step carries rather than a guessed "infeasible/unbounded" cause.
165
+ """
166
+ last_step = None
167
+ for name, step in steps.items():
168
+ last_step = step
169
+ # Phase C.5 — intermediate steps no longer hold ``solution``
170
+ # under default (slim) cascade; read the slim ``optimal``
171
+ # summary instead so the non-optimal check works without
172
+ # ``keep_solutions=True``. ``step.solution`` is only populated
173
+ # for the LAST step (or every step under ``keep_solutions``).
174
+ #
175
+ # ``optimal`` is the STRICT HiGHS verdict; ``near_optimal`` flags a
176
+ # solve ``classify_acceptance`` accepted despite a non-``kOptimal``
177
+ # status (crossover-off interior point). Both mean "usable
178
+ # solution written" — treat either as success.
179
+ if step.optimal or getattr(step, "near_optimal", False):
180
+ continue
181
+ if step.is_benders and step.obj is not None and math.isfinite(step.obj):
182
+ # Non-convergence with a feasible incumbent — warn loudly, keep
183
+ # the written results, do NOT fail the run.
184
+ logging.error(_benders_nonconvergence_banner(name, step))
185
+ continue
186
+ logging.error(
187
+ "Native cascade: solve %r did not solve to optimality; exit=1. "
188
+ "This solve's solution was not accepted for consumption "
189
+ "(strict-optimal=%r, near-optimal-accepted=%r, benders=%r, "
190
+ "objective=%r). A genuine LP failure names its precise cause "
191
+ "(infeasible / unbounded / limit reached) at the solve site; a "
192
+ "Benders solve reaching here found no feasible incumbent. See the "
193
+ "solve log above for the solver's reported status.",
194
+ name,
195
+ step.optimal,
196
+ getattr(step, "near_optimal", False),
197
+ step.is_benders,
198
+ step.obj,
199
+ )
200
+ return 1, step
201
+ return 0, last_step
202
+
203
+
204
+ def _benders_nonconvergence_banner(name, step) -> str:
205
+ """A loud, plain-English banner for a Benders solve that found a feasible
206
+ incumbent but never met its convergence tolerance.
207
+
208
+ Kept in FlexTool class vocabulary (node group / connection / flow) — never
209
+ model-instance terms — mirroring the ``_benders_failure_message`` contract.
210
+ """
211
+ def _pct(x):
212
+ return f"{x * 100:.4g}%" if x is not None and math.isfinite(x) else "unknown"
213
+
214
+ gap = getattr(step, "benders_gap", None)
215
+ tol = getattr(step, "benders_tol", None)
216
+ iters = getattr(step, "benders_iterations", None)
217
+ bar = "#" * 76
218
+ return (
219
+ f"\n{bar}\n"
220
+ f"WARNING: decomposed solve {name!r} did NOT meet its convergence "
221
+ f"tolerance.\n"
222
+ f"{bar}\n"
223
+ f" Relative gap reached : {_pct(gap)}\n"
224
+ f" Required tolerance : {_pct(tol)}\n"
225
+ f" Iterations run : {iters if iters is not None else 'unknown'}\n"
226
+ f" Best feasible cost : {step.obj:.6g}\n"
227
+ f"\n"
228
+ f" The results ARE feasible and HAVE been written to the outputs "
229
+ f"(parquet /\n"
230
+ f" results DB), but they are NOT certified optimal: the true optimum "
231
+ f"may be\n"
232
+ f" up to the gap above cheaper than the reported cost.\n"
233
+ f"\n"
234
+ f" To close the gap, try any of: raise the decomposition iteration "
235
+ f"limit,\n"
236
+ f" loosen the convergence tolerance, set the in-out stabilization "
237
+ f"weight\n"
238
+ f" (~0.3-0.7) to break a stalled plateau, or give any under-supplied "
239
+ f"node\n"
240
+ f" group a finite fail-safe import price on its boundary nodes.\n"
241
+ f"{bar}"
242
+ )
243
+
244
+
245
+ def resolve_output_path(input_db_url, flextool_location, output_location, cwd,
246
+ project_folder_file=None):
247
+ """Resolve the TRUE output root for a CLI run (5-tier rule).
248
+
249
+ This is the path that outputs actually land under, so it is what
250
+ gets persisted to the "Output info" DB as ``scenario/output_location``
251
+ (Toolbox's comparison / re-create steps read it back to locate each
252
+ scenario's parquet) AND what is passed as ``fallback_output_location``
253
+ to ``write_outputs`` and used for the timings.csv directory.
254
+
255
+ The five tiers, in precedence order:
256
+
257
+ 1. **Explicit ``--output-location``** wins outright. ``write_outputs``
258
+ already honours an explicit ``output_location`` over the
259
+ ``fallback_output_location`` (see ``_resolve_settings``), so before
260
+ this change an explicit ``--output-location`` steered where files
261
+ were written but was NOT reflected in the persisted Output-info
262
+ record (which used the fallback ``output_path``). Folding it into
263
+ tier 1 fixes that latent inconsistency: the recorded location now
264
+ matches where the files actually go.
265
+
266
+ 2. **Project-folder file (``--project-folder-file``).** This is the
267
+ USER-LOCAL output redirect: the maintainer points Spine Toolbox's
268
+ FlexTool run Tool at a gitignored file (seeded by ``self_update``
269
+ as ``templates/project_folder.txt``) whose CONTENTS name a project
270
+ folder, so a user can redirect every output (``output_parquet/``,
271
+ ``results.sqlite``, plots, the per-project ``plot_settings.yaml``)
272
+ into a per-project directory with ZERO git-committed change.
273
+
274
+ The file's first non-empty, non-``#``-comment line is the project
275
+ folder. If that line is an ABSOLUTE path it is used verbatim; if
276
+ RELATIVE it is resolved against the file's repo anchor —
277
+ ``file.resolve().parent.parent`` — the SAME anchor the legacy
278
+ ``--flextool-location`` walk uses, so a ``templates/
279
+ project_folder.txt`` line of ``projects/Rivendell`` lands the
280
+ output at ``<repo>/projects/Rivendell``.
281
+
282
+ **A supplied ``--project-folder-file`` is a COMPLETE replacement
283
+ for the legacy ``--flextool-location`` and therefore NEVER falls
284
+ through to CWD.** When the file is missing, unreadable, empty, or
285
+ comment-only — i.e. its CONTENTS name no folder — this tier still
286
+ fires, falling back to the FILE'S repo anchor
287
+ (``Path(project_folder_file).resolve().parent.parent``), the same
288
+ FlexTool root the legacy ``--flextool-location`` walk produced.
289
+ This matches the seeded ``templates/project_folder.txt`` whose own
290
+ comment says "Leave blank to use the FlexTool root". Only when
291
+ ``--project-folder-file`` was NOT supplied at all (None / empty
292
+ arg) does resolution continue to tiers 3-5.
293
+
294
+ 3. **GUI-project layout.** When the input DB file sits directly inside
295
+ a directory named ``input_sources`` (the FlexTool GUI project
296
+ layout, ``<project>/input_sources/<db>.sqlite``), the output root is
297
+ that directory's parent — the project folder. This is
298
+ location-agnostic: it works wherever the project lives on disk.
299
+ The DB filesystem path is recovered from ``input_db_url`` (which may
300
+ be a ``sqlite:///`` URL possibly carrying an appended filter
301
+ query-config) using the same ``sqlite:///`` stripping idiom used
302
+ elsewhere in this file, plus a query-part strip. If the path can't
303
+ be resolved to an existing file, this tier is skipped (no crash).
304
+
305
+ 4. **Legacy ``--flextool-location`` bridge.** ``.parent.parent`` of the
306
+ resolved flextool-location path (the historical Spine Toolbox
307
+ anchor; kept for backward compatibility one release).
308
+
309
+ 5. **Fallback** to the current working directory.
310
+
311
+ Pure path logic — deterministic, no randomness, no side effects.
312
+ """
313
+ # Tier 1 — explicit override wins.
314
+ if output_location:
315
+ return Path(output_location)
316
+
317
+ # Tier 2 — project-folder file. A supplied --project-folder-file is a
318
+ # COMPLETE replacement for --flextool-location: it ALWAYS yields an
319
+ # output root and never falls through to CWD. Its CONTENTS name the
320
+ # project folder when present; otherwise (blank / comment-only /
321
+ # missing / unreadable) we fall back to the FILE'S repo anchor
322
+ # (.parent.parent), the same FlexTool root the legacy
323
+ # --flextool-location walk produced. Only an unsupplied (None / empty)
324
+ # arg lets resolution continue to tiers 3-5.
325
+ if project_folder_file:
326
+ project_folder = _read_project_folder_file(project_folder_file)
327
+ if project_folder is not None:
328
+ return project_folder
329
+ # CONTENTS name no folder — anchor at the file's repo root.
330
+ try:
331
+ return Path(project_folder_file).resolve().parent.parent
332
+ except OSError:
333
+ # ``resolve()`` should not raise for a plain path on POSIX even
334
+ # when it doesn't exist, but degrade without crashing if it
335
+ # ever does: anchor at the un-resolved path's .parent.parent.
336
+ return Path(project_folder_file).parent.parent
337
+
338
+ # Tier 3 — GUI project layout: <project>/input_sources/<db>.sqlite.
339
+ db_fs_path = _input_db_filesystem_path(input_db_url)
340
+ if db_fs_path is not None:
341
+ try:
342
+ resolved = db_fs_path.resolve()
343
+ except OSError:
344
+ resolved = None
345
+ if resolved is not None and resolved.is_file() \
346
+ and resolved.parent.name == "input_sources":
347
+ return resolved.parent.parent
348
+
349
+ # Tier 4 — legacy flextool-location anchor walk.
350
+ if flextool_location:
351
+ return Path(flextool_location).resolve().parent.parent
352
+
353
+ # Tier 5 — fallback to CWD.
354
+ return Path(cwd)
355
+
356
+
357
+ def _read_project_folder_file(project_folder_file):
358
+ """Resolve a project-folder redirect from a ``--project-folder-file``.
359
+
360
+ The file's CONTENTS name the project folder: the first non-empty,
361
+ non-``#``-comment line is taken (whitespace-stripped). An ABSOLUTE
362
+ line is returned verbatim; a RELATIVE line is resolved against the
363
+ file's repo anchor (``file.resolve().parent.parent``, the same anchor
364
+ the legacy ``--flextool-location`` walk uses), so a ``templates/
365
+ project_folder.txt`` line of ``projects/Rivendell`` maps to
366
+ ``<repo>/projects/Rivendell``.
367
+
368
+ Returns a :class:`~pathlib.Path` when the file's CONTENTS name a
369
+ usable project folder, or ``None`` when the path arg is empty, the
370
+ file is missing / unreadable, or it has no non-comment content. A
371
+ ``None`` return does NOT mean "fall through to CWD": the caller
372
+ (``resolve_output_path``) treats a supplied-but-content-less
373
+ ``--project-folder-file`` as the FlexTool root by anchoring at the
374
+ file's ``.parent.parent`` — so this tier never reaches CWD once the
375
+ arg is supplied. Robust: any read error → ``None`` (never raises).
376
+ """
377
+ if not project_folder_file:
378
+ return None
379
+ file_path = Path(project_folder_file)
380
+ try:
381
+ text = file_path.read_text(encoding="utf-8")
382
+ except OSError:
383
+ # Missing / unreadable / not a file — skip this tier silently.
384
+ return None
385
+ line = None
386
+ for raw in text.splitlines():
387
+ stripped = raw.strip()
388
+ if not stripped or stripped.startswith("#"):
389
+ continue
390
+ line = stripped
391
+ break
392
+ if line is None:
393
+ # Empty or comment-only — fall through.
394
+ return None
395
+ candidate = Path(line)
396
+ if candidate.is_absolute():
397
+ return candidate
398
+ # Relative → resolve against the file's repo anchor (parent.parent),
399
+ # matching the legacy --flextool-location walk so a templates/-anchored
400
+ # relative line roots at the repo root.
401
+ try:
402
+ anchor = file_path.resolve().parent.parent
403
+ except OSError:
404
+ return None
405
+ return anchor / candidate
406
+
407
+
408
+ def _input_db_filesystem_path(input_db_url):
409
+ """Best-effort filesystem path for a (possibly sqlite) input DB URL.
410
+
411
+ Returns a :class:`~pathlib.Path` for ``sqlite:///`` URLs and bare
412
+ filesystem paths, stripping any appended filter query-config
413
+ (``?spinedbfilter=...``); returns ``None`` for non-sqlite URLs (e.g.
414
+ ``mysql://``) or when the value is empty. Does not touch the
415
+ filesystem — purely string → path.
416
+ """
417
+ if not input_db_url:
418
+ return None
419
+ # A non-sqlite scheme (mysql, postgresql, …) is not a local file.
420
+ if "://" in input_db_url and not input_db_url.startswith("sqlite:"):
421
+ return None
422
+ # Strip an appended Spine filter query-config, e.g.
423
+ # ``sqlite:///proj/input_sources/db.sqlite?spinedbfilter=...``.
424
+ # ``urlsplit`` keeps everything before ``?`` in ``.path`` for URLs and
425
+ # leaves a bare path untouched in ``.path`` too, but to stay aligned
426
+ # with the file-local ``.replace('sqlite:///', '')`` idiom we strip the
427
+ # scheme prefix first, then split off the query manually.
428
+ no_scheme = input_db_url.replace("sqlite:///", "", 1)
429
+ no_query = no_scheme.split("?", 1)[0]
430
+ if not no_query:
431
+ return None
432
+ return Path(no_query)
433
+
434
+
435
+ def main():
436
+ parser = argparse.ArgumentParser()
437
+ parser.description = "Run flextool using the specified database URL. Return codes are 0: success, 1: infeasible or unbounded, -1: failure."
438
+ parser.add_argument('input_db_url', help='Database URL to connect to (can be copied from Toolbox workflow db item')
439
+ parser.add_argument('output_db_url', metavar='DB_URL', nargs='?', default=None, help='Save information about result location to database for post-processing')
440
+ parser.add_argument('--settings-db-url', help='Settings for post-processing')
441
+ parser.add_argument('--scenario-name', help='Name for the scenario in the database that should be executed', nargs='?', default=None)
442
+ parser.add_argument(
443
+ '--debug',
444
+ nargs='?',
445
+ const='basic',
446
+ default='off',
447
+ choices=['off', 'basic', 'full'],
448
+ metavar='LEVEL',
449
+ help='Diagnostic verbosity level (default: off). '
450
+ '``off`` — quiet; only user-facing INFO and WARNING. '
451
+ '``basic`` — verbose memory checkpoint trace + DEBUG log '
452
+ 'level; no tracemalloc, no perf overhead beyond extra '
453
+ 'stdout. Bare ``--debug`` selects this level. '
454
+ '``full`` — basic plus tracemalloc-backed memory '
455
+ 'diagnostics CSV. Tracemalloc instruments every Python '
456
+ 'allocation and typically slows allocation-heavy phases '
457
+ '(input_derivation, cascade rolls) by 2-5×; use only '
458
+ 'when investigating Python-side allocation regressions.',
459
+ )
460
+ parser.add_argument(
461
+ '--save-memory', action='store_true',
462
+ help='Trade wall time for peak memory: build the LP, write it to '
463
+ 'a temp MPS file, drop everything Python-side AND the live '
464
+ 'HiGHS instance, then spawn a separate subprocess to solve '
465
+ 'the MPS in a clean address space. The parent process '
466
+ '(holding ~7-11 GB of polars frames + FlexData) sits idle '
467
+ 'while the child does its ~50 GB active-solve work, so the '
468
+ 'two never compound in the same process heap. Adds ~+30-60 s '
469
+ 'I/O per sub-solve. Also disables warm-LP reuse across '
470
+ 'cascade iterations (the Problem is released after MPS '
471
+ 'write). Off by default; use when models OOM on the default '
472
+ 'in-process path.',
473
+ )
474
+ parser.add_argument(
475
+ '--warm-start', action='store_true',
476
+ help='Reuse a cached HiGHS basis across solves of the same '
477
+ 'structural model (save-memory subprocess path only); safe '
478
+ 'cold fallback on any mismatch.',
479
+ )
480
+ parser.add_argument('--output-spreadsheet', metavar='PATH', help='Save results to spreadsheet file')
481
+ parser.add_argument('--write-methods', type=str, nargs='+', default=None,
482
+ choices=['plot', 'parquet', 'excel', 'csv', 'spinedb'],
483
+ help='Output methods to use (default: plot parquet)')
484
+ parser.add_argument('--results-db-url', type=str, default=None,
485
+ help='Target SpineDB URL for the spinedb write-method (default: <output-dir>/results.sqlite)')
486
+ parser.add_argument('--output-config', metavar='PATH',
487
+ default=None,
488
+ help='Path to output configuration file (default: templates/default_plots.yaml)')
489
+ parser.add_argument('--active-configs', type=str, nargs='+', default=None,
490
+ help='Active output configurations to use (default: default)')
491
+ parser.add_argument('--plot-rows', type=int, nargs=2, default=None, metavar=('FIRST', 'LAST'),
492
+ help='First and last row to plot in time series (default: 0 167)')
493
+ parser.add_argument('--output-location', metavar='PATH', default=None,
494
+ help='Override output location path')
495
+ parser.add_argument('--output-subdir', metavar='NAME', default=None,
496
+ help='Subdirectory name under output_parquet/ (and the '
497
+ 'other output dirs). Defaults to the scenario '
498
+ 'name for backward compatibility.')
499
+ parser.add_argument('--flextool-location', nargs='?', default=None, const=None,
500
+ help='When running in Spine Toolbox, this argument provides the location of FlexTool so outputs can be directed there (instead of work directories). Defaults to the user\'s current working directory. The value may be omitted (Spine Toolbox sometimes passes the bare flag) — in that case the default is used. Legacy bridge: kept for backward compatibility; prefer --project-folder-file.')
501
+ parser.add_argument('--project-folder-file', metavar='PATH', default=None,
502
+ help='Path to a USER-LOCAL file whose CONTENTS name '
503
+ 'the project folder to direct outputs into '
504
+ '(output_parquet/, results.sqlite, plots, and '
505
+ 'the per-project plot_settings.yaml). The '
506
+ 'first non-empty, non-#-comment line is the '
507
+ 'project folder: an absolute path is used as-is; '
508
+ 'a relative path is resolved against the file\'s '
509
+ 'repo anchor (its .parent.parent). Spine Toolbox '
510
+ 'points this at templates/project_folder.txt '
511
+ '(gitignored, seeded by flextool-update). This '
512
+ 'flag is a COMPLETE replacement for '
513
+ '--flextool-location: a missing / empty / '
514
+ 'comment-only file does NOT fall through to the '
515
+ 'work dir but anchors at the file\'s repo root '
516
+ '(its .parent.parent), so a supplied '
517
+ '--project-folder-file never lands outputs in '
518
+ 'the CWD. Lower precedence than '
519
+ '--output-location, higher than the '
520
+ 'input_sources/ layout and --flextool-location.')
521
+ parser.add_argument('--work-folder', metavar='PATH', default=None,
522
+ help='Working directory for intermediate files (default: current directory). '
523
+ 'Enables parallel scenario execution by isolating each run.')
524
+ parser.add_argument('--only-first-file-per-plot', action='store_true', default=False,
525
+ help='Only produce the first file for each plot (quick overview mode)')
526
+ parser.add_argument('--precision-digits', metavar='N', type=int, default=None,
527
+ help='Round every numeric input parameter to N significant '
528
+ 'figures before writing CSVs (typical: 10). '
529
+ 'Collapses accumulated float-noise so HiGHS '
530
+ 'mip_detect_symmetry can aggregate structurally-identical '
531
+ 'coefficients. 0 or unset disables rounding (default). '
532
+ 'Overrides the FLEXTOOL_PRECISION_DIGITS env var.')
533
+ parser.add_argument('--region', metavar='GROUP_NAME', default=None,
534
+ help='Produce a filtered per-region input directory '
535
+ '``input_region_<GROUP_NAME>/`` for Benders '
536
+ 'decomposition (Agent 3.1). The group must have '
537
+ '``decomposition_method=benders_regional`` in '
538
+ 'the DB. Cross-region processes are replaced '
539
+ 'with import/export half-flows; the coupling '
540
+ 'variables are listed in '
541
+ '``solve_data/region_coupling.csv``. When this '
542
+ 'flag is set, no solve runs — this is the '
543
+ 'filter-only entry point used by the coordinator.')
544
+ # Decomposition is DB-driven and per-solve (v62): set
545
+ # ``solve.decomposition = benders`` plus the per-solve
546
+ # ``solve.benders_max_iter`` / ``benders_tolerance`` /
547
+ # ``benders_in_out_weight`` knobs in the database. The old global ``--decomposition`` / ``--lagrangian-*``
548
+ # CLI flags were removed — the orchestrator reads the scheme per solve
549
+ # so a single chain can mix monolithic and Benders solves. See
550
+ # docs/dev/decomposition.md.
551
+ parser.add_argument('--highs-threads', type=int, default=1,
552
+ help='Number of HiGHS solver threads. Default 1. '
553
+ 'Values > 1 enable HiGHS parallel mode and trade '
554
+ 'determinism for wall-clock speedup; goldens are '
555
+ 'not guaranteed to reproduce across runs in that '
556
+ 'mode.')
557
+ parser.add_argument(
558
+ '--scaling',
559
+ choices=['off', 'solver_only', 'basic', 'full'],
560
+ default=None,
561
+ help=(
562
+ "Choose FlexTool's autoscaler strategy. HiGHS' internal matrix "
563
+ "equilibration (simplex_scale_strategy) is unaffected by this "
564
+ "flag EXCEPT when --scaling=off, where it is forced to 0. To "
565
+ "tune HiGHS-internal options, use the solver config file.\n"
566
+ "\n"
567
+ " off Disable ALL scaling, including HiGHS' internal "
568
+ "matrix equilibration (forces simplex_scale_strategy=0). Use "
569
+ "this if you want raw numerics or to export the truly unscaled "
570
+ "LP. Expect HiGHS warnings.\n"
571
+ " solver_only Disable the FlexTool autoscaler. HiGHS still "
572
+ "scales the matrix internally per its own default "
573
+ "(simplex_scale_strategy=2, equilibration). Useful when "
574
+ "exporting MPS for an external solver.\n"
575
+ " basic Compute LP ranges (Layer 1) and recommend "
576
+ "power-of-two user_objective_scale + user_bound_scale to HiGHS "
577
+ "(Layer 3). No LP-array mutation; MPS exports reflect the "
578
+ "unscaled model. HiGHS' own matrix equilibration runs per its "
579
+ "default.\n"
580
+ " full The full autoscaler: range detection (Layer 1), "
581
+ "semantic per-type column/row/cost scaling of the LP arrays "
582
+ "(Layer 2), and HiGHS user_*_scale recommendation (Layer 3). "
583
+ "Produces the most robust conditioning. Default.\n"
584
+ "\n"
585
+ "Precedence for user_objective_scale and user_bound_scale:\n"
586
+ " 1. --user-bound-scale N (CLI override)\n"
587
+ " 2. user_*_scale set via solver config file\n"
588
+ " 3. Layer 3 autoscaler recommendation\n"
589
+ " 4. HiGHS default (0)\n"
590
+ "\n"
591
+ "Env fallback: FLEXTOOL_SCALING."
592
+ ),
593
+ )
594
+ parser.add_argument('--user-bound-scale', type=int, default=None,
595
+ metavar='N',
596
+ help='HiGHS ``user_bound_scale`` override (power of '
597
+ 'two: multiplies all col bounds and RHS by '
598
+ '2**N). When HiGHS prints '
599
+ '"Consider setting the user_bound_scale option '
600
+ 'to <N>" in its scaling warning, pass that '
601
+ '<N> here. Clamped to [-10, 0]. Overrides '
602
+ 'any DB value; falls through to the '
603
+ 'input-data heuristic when unset.')
604
+ parser.add_argument('--presolve', choices=['on', 'off', 'choose'],
605
+ default=None,
606
+ help='HiGHS ``presolve`` override. Default '
607
+ '(unset) keeps the determinism-pinned '
608
+ '"on" setting from '
609
+ '``DETERMINISM_OPTIONS``. ``off`` disables '
610
+ 'presolve entirely (much slower but useful '
611
+ 'for memory or numerical diagnostics).')
612
+ parser.add_argument('--solver-log-level',
613
+ choices=['silent', 'normal', 'verbose'],
614
+ default=None,
615
+ help='HiGHS log verbosity. ``silent`` sets '
616
+ '``output_flag=false`` (suppress HiGHS '
617
+ 'console output). ``normal`` (default) '
618
+ 'and ``verbose`` both set '
619
+ '``output_flag=true``; ``verbose`` also '
620
+ 'bumps ``log_dev_level=2`` for per-'
621
+ 'iteration solver telemetry. Replaces '
622
+ 'the v55-era DB-stored solver_log_level '
623
+ 'knob (removed in Batch C.7).')
624
+ parser.add_argument('--solver-time-limit', type=float, default=None,
625
+ metavar='SECONDS',
626
+ help='HiGHS wall-clock time limit '
627
+ '(``time_limit`` option, seconds). '
628
+ 'Unset (default) means no limit. '
629
+ 'Replaces the v55-era DB-stored '
630
+ 'solver_time_limit knob (removed in '
631
+ 'Batch C.8). Routed through the '
632
+ 'effective-options resolver as a CLI '
633
+ 'override (highest precedence).')
634
+ parser.add_argument('--solver-mip-gap', type=float, default=None,
635
+ metavar='GAP',
636
+ help='HiGHS MIP relative optimality gap '
637
+ '(``mip_rel_gap`` option). Unset (default) '
638
+ 'keeps HiGHS\' built-in 1e-4. Only affects '
639
+ 'MIP solves (integer investments, '
640
+ 'unit-commitment / online variables); '
641
+ 'pure-LP solves ignore it. Routed through '
642
+ 'the effective-options resolver as a CLI '
643
+ 'override (highest precedence).')
644
+ parser.add_argument('--matrix-file-format',
645
+ choices=['mps', 'lp'],
646
+ default=None,
647
+ help='On-disk format used when the solver is '
648
+ 'dispatched via a matrix file: ``mps`` '
649
+ '(default) or ``lp``. The in-process '
650
+ 'vs. file decision is implicit:\n'
651
+ '* HiGHS + no ``--save-memory`` -> direct '
652
+ '(in-process binding, fastest).\n'
653
+ '* HiGHS + ``--save-memory`` -> file write '
654
+ '(polar-high round-trips through MPS '
655
+ 'internally; this flag has no effect).\n'
656
+ '* Commercial solver (gurobi / cplex / '
657
+ 'xpress / copt) -> file write using the '
658
+ 'chosen format.\n'
659
+ 'Replaces the v55-era ``--solver-io-api`` '
660
+ 'flag; the engine still uses '
661
+ '``direct|mps|lp`` internally for '
662
+ '``SolverConfig.io_api``.')
663
+ parser.add_argument('--csv-dump', action='store_true',
664
+ default=False,
665
+ help='Debug visibility for cascade-internal '
666
+ 'artefacts. Default: the cascade keeps '
667
+ 'input/, solve_data/, cross_solve/, and '
668
+ 'output_raw/ off disk in the final work '
669
+ 'folder, leaving only the user-facing '
670
+ 'output_parquet/<scenario>/ tree (plus any '
671
+ 'output_csv/, output_excel/, output_plots/ '
672
+ 'requested via --write-methods). With the '
673
+ 'flag set, every intermediate directory '
674
+ 'survives the run for inspection.')
675
+
676
+ args = parser.parse_args()
677
+ # --user-bound-scale / --presolve are surfaced through env vars
678
+ # read by ``_orchestration._finalise_highs_options`` and the
679
+ # cascade's user_bound_scale resolution. Env vars keep the
680
+ # threading shallow: no new kwargs on run_chain_from_db /
681
+ # run_orchestration / _drive_cascade required.
682
+ if args.user_bound_scale is not None:
683
+ os.environ['FLEXTOOL_USER_BOUND_SCALE'] = str(args.user_bound_scale)
684
+ if args.presolve is not None:
685
+ os.environ['FLEXTOOL_HIGHS_PRESOLVE'] = args.presolve
686
+ if args.highs_threads is not None and args.highs_threads >= 1:
687
+ os.environ['FLEXTOOL_HIGHS_THREADS'] = str(args.highs_threads)
688
+ if args.solver_log_level is not None:
689
+ os.environ['FLEXTOOL_SOLVER_LOG_LEVEL'] = args.solver_log_level
690
+ if args.solver_time_limit is not None:
691
+ # Use the existing FLEXTOOL_HIGHS_TIME_LIMIT env var which the
692
+ # orchestrator's CLI-overrides builder already consults; the
693
+ # name is a historical artefact from the diagnostic shim that
694
+ # predated the resolver but the semantics are identical.
695
+ os.environ['FLEXTOOL_HIGHS_TIME_LIMIT'] = str(args.solver_time_limit)
696
+ if args.solver_mip_gap is not None:
697
+ os.environ['FLEXTOOL_HIGHS_MIP_GAP'] = str(args.solver_mip_gap)
698
+ if args.matrix_file_format is not None:
699
+ os.environ['FLEXTOOL_MATRIX_FILE_FORMAT'] = args.matrix_file_format
700
+ # ``--scaling`` (off/solver_only/basic/full) — CLI > env > default-full.
701
+ # Surfacing via the same ``FLEXTOOL_SCALING`` env var that
702
+ # ``resolve_scaling_config`` already consults keeps the threading
703
+ # shallow (no new kwargs on run_chain_from_db / run_orchestration /
704
+ # _drive_cascade). When the flag is unset (``args.scaling is None``)
705
+ # the existing env value — if any — survives untouched, preserving
706
+ # the env-fallback contract.
707
+ if args.scaling is not None:
708
+ os.environ['FLEXTOOL_SCALING'] = args.scaling
709
+ # ``--save-memory`` — opt-in peak-RSS reduction at solve time.
710
+ # Plumbed via env var so the orchestrator picks it up without an
711
+ # extra kwarg on ``run_chain_from_db`` / ``_drive_cascade``.
712
+ if args.save_memory:
713
+ os.environ['FLEXTOOL_SAVE_MEMORY'] = '1'
714
+ # ``--warm-start`` — opt-in HiGHS basis reuse across structurally
715
+ # identical solves (save-memory subprocess path only). Plumbed via
716
+ # env var like ``--save-memory``; the subprocess solver reads it
717
+ # directly and fails safe to a cold solve on any mismatch.
718
+ if args.warm_start:
719
+ os.environ['FLEXTOOL_WARM_START'] = '1'
720
+
721
+ # Accept either a SQLAlchemy URL ("sqlite:///path") or a bare
722
+ # filesystem path ("path/to.sqlite") for any DB argument. Downstream
723
+ # readers (SpineDbReader) already do this, but DatabaseMapping calls
724
+ # in this file consume the args directly, so normalise once here.
725
+ def _as_db_url(value):
726
+ if value is None:
727
+ return None
728
+ return value if "://" in value else f"sqlite:///{value}"
729
+
730
+ args.input_db_url = _as_db_url(args.input_db_url)
731
+ args.output_db_url = _as_db_url(args.output_db_url)
732
+ args.settings_db_url = _as_db_url(args.settings_db_url)
733
+
734
+ input_db_url = args.input_db_url
735
+ settings_db_url = args.settings_db_url
736
+ scenario_name = args.scenario_name
737
+ debug_level = args.debug # 'off' | 'basic' | 'full'
738
+ DEBUG = debug_level != 'off'
739
+ # ``--debug=basic`` widens stdout to include the full per-checkpoint
740
+ # phase-progress trace (every memory recorder event, not just the
741
+ # six whitelisted phase labels). ``--debug=full`` additionally
742
+ # enables tracemalloc-backed diagnostics that write the
743
+ # per-checkpoint CSV to ``solve_data/memory_diagnostics.csv`` — the
744
+ # tracemalloc instrumentation typically slows allocation-heavy
745
+ # phases by 2-5×, so it is gated to the explicit ``full`` opt-in.
746
+ # ``setdefault`` lets a caller still override either env var.
747
+ if debug_level in ('basic', 'full'):
748
+ os.environ.setdefault('FLEXTOOL_MEMORY_VERBOSE', '1')
749
+ if debug_level == 'full':
750
+ os.environ.setdefault('FLEXTOOL_MEMORY_DIAGNOSTICS', '1')
751
+ # The TRUE output root (where outputs land, and what is persisted to
752
+ # the "Output info" DB as scenario/output_location) is resolved by a
753
+ # 5-tier rule (see ``resolve_output_path`` for the full rationale):
754
+ # 1. ``--output-location`` (explicit wins),
755
+ # 2. ``--project-folder-file`` (user-local
756
+ # CONTENTS name a project folder; when blank/ redirect; a
757
+ # missing, the file's .parent.parent repo root) supplied file
758
+ # NEVER falls
759
+ # through to CWD),
760
+ # 3. ``<project>`` when the input DB sits in an (GUI project
761
+ # ``input_sources/`` dir layout),
762
+ # 4. ``--flextool-location``.parent.parent (legacy bridge),
763
+ # 5. CWD (fallback).
764
+ # Tiers 3-5 are only reached when NO --project-folder-file is supplied.
765
+ output_path = resolve_output_path(
766
+ input_db_url=args.input_db_url,
767
+ flextool_location=args.flextool_location,
768
+ output_location=args.output_location,
769
+ cwd=Path.cwd(),
770
+ project_folder_file=args.project_folder_file,
771
+ )
772
+ work_folder = Path(args.work_folder) if args.work_folder else Path.cwd()
773
+ work_folder.mkdir(parents=True, exist_ok=True)
774
+ wf = work_folder
775
+
776
+ # Default formatter strips the ``INFO:<file>:<line>:`` preamble so
777
+ # user-facing INFO lines (license status, solver progress, etc.)
778
+ # read as plain prose. WARNING / ERROR carry their level via the
779
+ # message body of the ``logging.warning(...)`` calls themselves
780
+ # ("Failed to ...", etc.), so dropping ``%(levelname)s`` here
781
+ # doesn't hide the severity. ``--debug`` restores the full
782
+ # prefix for diagnosis.
783
+ logging.basicConfig(
784
+ level=logging.DEBUG if DEBUG else logging.INFO,
785
+ format=(
786
+ '%(levelname)s:%(filename)s:%(lineno)d:%(message)s'
787
+ if DEBUG else '%(message)s'
788
+ ),
789
+ handlers=[logging.StreamHandler(sys.stdout)]
790
+ )
791
+ if not DEBUG:
792
+ # Silence routine "wrote …" / "Wrote N output variables" INFO
793
+ # chatter from the per-solve output + handoff writers in regular
794
+ # mode. These fire on every sub-solve and tell the user
795
+ # nothing they can't infer from "Solver" + the parquet
796
+ # contents. WARNINGs (failed writes, missing files, etc.)
797
+ # still surface because we only raise the writer-module
798
+ # thresholds to WARNING. --debug restores the full chatter.
799
+ for _noisy in (
800
+ "flextool.process_outputs.handoff_writers",
801
+ "flextool.process_outputs.read_highs_solution",
802
+ "flextool.engine_polars.handoff_writers",
803
+ ):
804
+ logging.getLogger(_noisy).setLevel(logging.WARNING)
805
+
806
+ # Self-heal missing lightweight settings DBs so fresh clones don't
807
+ # fail opaquely when the user forgot to run `flextool-update`. Only
808
+ # seeds output_info / output_settings / comparison_settings by
809
+ # basename; other paths are left untouched.
810
+ for _candidate in (args.output_db_url, args.settings_db_url):
811
+ try:
812
+ ensure_settings_db(_candidate)
813
+ except Exception as _exc:
814
+ logging.warning("Failed to auto-seed %s: %s", _candidate, _exc)
815
+
816
+ # Phase-timing recorder: constructed once per CLI invocation, lives
817
+ # on ``runner.state.timing_recorder``, writes a structured timings.csv
818
+ # at <work_folder>/solve_data/timings.csv (one row per phase, atomic
819
+ # append style so a crash mid-run still leaves usable data).
820
+ timing_recorder = TimingRecorder(work_folder=wf, scenario=scenario_name)
821
+ t_total_start = time.perf_counter()
822
+
823
+ # resolve_precision_digits respects FLEXTOOL_PRECISION_DIGITS env override.
824
+ effective_precision = resolve_precision_digits(args.precision_digits)
825
+
826
+ # --- Regional filter mode (Agent 3.1) --------------------------------
827
+ # ``--region GROUP`` produces ``input_region_<GROUP>/`` and exits
828
+ # without invoking the solver. The Benders coordinator (Agent
829
+ # 3.2) then orchestrates multiple region solves itself.
830
+ if args.region:
831
+ from flextool.decomposition.region_decomposition import (
832
+ write_input_for_region as _write_input_for_region,
833
+ )
834
+ _region_output = wf / f"input_region_{args.region}"
835
+ try:
836
+ result = _write_input_for_region(
837
+ input_db_url=input_db_url,
838
+ scenario_name=scenario_name,
839
+ logger=logging.getLogger("flextool.region_filter"),
840
+ region_group=args.region,
841
+ output_dir=_region_output,
842
+ work_folder=work_folder,
843
+ precision_digits=effective_precision,
844
+ )
845
+ except Exception as exc:
846
+ logging.error("Regional filter failed: %s", exc, exc_info=True)
847
+ sys.exit(-1)
848
+ print(f"Wrote filtered region inputs to {_region_output}")
849
+ print(
850
+ f"Coupling variables ({len(result['half_flows'])}): "
851
+ f"{[hf.virtual_node for hf in result['half_flows']]}"
852
+ )
853
+ sys.exit(0)
854
+
855
+ # Benders decomposition is now DB-driven and per-solve: the
856
+ # orchestrator reads ``solve.decomposition`` for each solve and runs
857
+ # the Benders region driver for the ones set to ``benders``
858
+ # (see engine_polars._orchestration / docs/dev/decomposition.md). The
859
+ # old global ``--decomposition lagrangian`` standalone path was
860
+ # removed; nothing special happens here — the normal run path below
861
+ # handles every scheme.
862
+
863
+ # Resolve scenario_name when omitted: pull it from the DB's
864
+ # active filter. ``run_chain_from_db`` accepts a None scenario
865
+ # but downstream ``SolveConfig.load_from_db_url`` requires a
866
+ # concrete name, so fix it up here.
867
+ if not scenario_name:
868
+ with DatabaseMapping(input_db_url) as db_map:
869
+ _filters = db_map.get_filter_configs()
870
+ if _filters:
871
+ scenario_name = name_from_dict(_filters[0])
872
+
873
+ # Header block — one aligned key/value pair per line, blank lines
874
+ # before and after, so the user has a self-contained summary of
875
+ # what's being run.
876
+ _header_pairs = [
877
+ ("Work dir", str(work_folder)),
878
+ ("DB URL", str(input_db_url)),
879
+ ("Scenario", scenario_name if scenario_name else "(unresolved)"),
880
+ ("Output", str(args.output_location or output_path)),
881
+ ]
882
+ _header_keyw = max(len(k) for k, _ in _header_pairs) + 2
883
+ print("")
884
+ for _k, _v in _header_pairs:
885
+ print(f"{(_k + ':').ljust(_header_keyw)}{_v}")
886
+ # No trailing blank line here -- the "Available solvers:" log emits
887
+ # its own trailing newline so the blank lands AFTER the licence line,
888
+ # not before it.
889
+
890
+ try:
891
+ return_code, last_step = _run_solve(
892
+ args, scenario_name, work_folder, timing_recorder,
893
+ )
894
+ except Exception as e:
895
+ # FlexToolUserError signals a user-visible configuration problem
896
+ # (unknown solver, missing license, model-level solver error).
897
+ # The message is already human-readable; logging the traceback
898
+ # on top is just noise. Other exceptions get the full
899
+ # traceback because they're (probably) bugs in flextool.
900
+ try:
901
+ from flextool.engine_polars._solver_dispatch import (
902
+ FlexToolUserError,
903
+ )
904
+ except Exception: # noqa: BLE001
905
+ FlexToolUserError = () # type: ignore[assignment]
906
+ if isinstance(e, FlexToolUserError):
907
+ logging.error(str(e))
908
+ else:
909
+ logging.error(
910
+ f"Native cascade failed: {str(e)}\n"
911
+ f"Traceback:\n{traceback.format_exc()}"
912
+ )
913
+ sys.exit(1)
914
+
915
+ # If successful and requested, write outputs
916
+ output_subdir = args.output_subdir or scenario_name
917
+ if return_code == 0:
918
+ t_write_outputs = time.perf_counter()
919
+ # Δ.31 — pass the last step's flex_data + solution so
920
+ # write_outputs can build par/s in memory. ``solve_name``
921
+ # is the complete sub-solve identifier (e.g. ``y2025_5week``
922
+ # for a roll, or just the scenario name for a single solve).
923
+ wo_solve_name = (
924
+ last_step.solve_name if last_step else None
925
+ ) or scenario_name
926
+ # A standalone Benders-only final solve carries only a
927
+ # SnapshotSolution invest carrier (not a full Solution), so it
928
+ # cannot yet drive processed outputs (TIER 2, planned follow-up).
929
+ # Emit a clear, targeted notice and SKIP write_outputs entirely
930
+ # rather than letting it fail and degrade to a generic warning.
931
+ # The invest→dispatch chain ends on a real dispatch Solution
932
+ # (is_benders=False) and is unaffected.
933
+ if last_step is not None and getattr(
934
+ last_step, "is_benders", False
935
+ ):
936
+ logging.info(
937
+ "Final solve '%s' ran under decomposition=benders and "
938
+ "does not yet produce processed outputs on its own. The "
939
+ "decomposition objective/region summary was logged above. "
940
+ "To get output files, add a downstream dispatch solve to "
941
+ "the chain (model.solves = [%s, <dispatch solve>]); the "
942
+ "dispatch solve produces the outputs. (Standalone "
943
+ "Benders output processing is a planned follow-up.)",
944
+ wo_solve_name,
945
+ wo_solve_name,
946
+ )
947
+ else:
948
+ try:
949
+ # Multi-solve (rolling) note: the last step alone would
950
+ # collapse par/s to the final roll's (d,t). write_outputs
951
+ # detects the per-roll realized slices persisted under
952
+ # ``output_raw/`` (``has_persisted_slices``) and unions them
953
+ # into the full-timeline par/s; ``last_step`` then only
954
+ # supplies the static (solve-invariant) attrs + the per-attr
955
+ # shape template. We deliberately do NOT pass ``solve_steps``
956
+ # here — the union activates on persisted-parquet presence and
957
+ # carries solve labels via parquet filenames + the
958
+ # ``output_raw/_solve_order.txt`` creation-order manifest.
959
+ wo_flex_data = last_step.flex_data if last_step else None
960
+ wo_solution = last_step.solution if last_step else None
961
+ write_outputs(
962
+ scenario_name=scenario_name,
963
+ output_location=args.output_location,
964
+ subdir=output_subdir,
965
+ output_config_path=args.output_config,
966
+ active_configs=args.active_configs,
967
+ write_methods=args.write_methods,
968
+ plot_rows=(
969
+ tuple(args.plot_rows) if args.plot_rows else None
970
+ ),
971
+ settings_db_url=settings_db_url,
972
+ fallback_output_location=str(output_path),
973
+ raw_output_dir=str(wf / 'output_raw'),
974
+ only_first_file=args.only_first_file_per_plot,
975
+ timing_recorder=timing_recorder,
976
+ flex_data=wo_flex_data,
977
+ solution=wo_solution,
978
+ solve_name=wo_solve_name,
979
+ flex_data_provider=getattr(
980
+ last_step, "flex_data_provider", None
981
+ ),
982
+ results_db_url=args.results_db_url,
983
+ )
984
+ except FileNotFoundError as exc:
985
+ # The in-memory parameter / set path doesn't read
986
+ # ``solve_data/`` CSVs, but ``read_variables`` still
987
+ # reads ``output_raw/`` parquets. Catch missing-parquet
988
+ # cases here (rare) and exit cleanly.
989
+ logging.warning(
990
+ "write_outputs failed (%s). output_raw/ artefacts "
991
+ "ARE produced; downstream output_csv/, output_parquet/, "
992
+ "output_excel/, output_plots/ are skipped on this run.",
993
+ exc,
994
+ )
995
+ timing_recorder.record('write_outputs', subphase='total',
996
+ seconds=time.perf_counter() - t_write_outputs,
997
+ t_start=t_write_outputs)
998
+
999
+ # output_raw/ is the cascade's intermediate parquet stash for
1000
+ # write_outputs to consume. Keep it on disk only when the user
1001
+ # opted in via --csv-dump (debug). On a normal run the user only
1002
+ # wants the canonical output_parquet/<scenario>/ tree.
1003
+ if not args.csv_dump:
1004
+ raw_dir = wf / 'output_raw'
1005
+ if raw_dir.exists():
1006
+ shutil.rmtree(raw_dir, ignore_errors=True)
1007
+
1008
+ full_seconds = time.perf_counter() - t_total_start
1009
+ print("\n--- Full execution time %.4s seconds ---------------------------------------" % full_seconds)
1010
+ print("--------------------------------------------------------------------------\n")
1011
+ timing_recorder.record('total', seconds=full_seconds, t_start=t_total_start)
1012
+
1013
+ # Move timings.csv into the per-scenario output dir alongside
1014
+ # summary_solve.csv. Mirror write_outputs's resolution of the output
1015
+ # location so the file lands in the same parent regardless of whether
1016
+ # output_location was supplied via the CLI / env / settings DB.
1017
+ try:
1018
+ _resolved_output_location = args.output_location or str(output_path) or ''
1019
+ _final_csv_dir = (
1020
+ Path(_resolved_output_location) / 'output_csv' / output_subdir
1021
+ if output_subdir else
1022
+ Path(_resolved_output_location) / 'output_csv'
1023
+ )
1024
+ timing_recorder.finalize(_final_csv_dir)
1025
+ except Exception as _exc:
1026
+ logging.warning("Failed to copy timings.csv to output dir: %s", _exc)
1027
+
1028
+ # Write scenario information to output database if provided
1029
+ if args.output_db_url:
1030
+ # Check if database exists
1031
+ db_exists = os.path.exists(args.output_db_url.replace('sqlite:///', ''))
1032
+
1033
+ with DatabaseMapping(args.output_db_url, create=not db_exists) as output_db:
1034
+ # Create/update scenario class if it doesn't exist
1035
+ output_db.add_or_update_entity_class(name="scenario")
1036
+
1037
+ # Create/update parameter definition for 'output_location'
1038
+ output_db.add_or_update_parameter_definition(
1039
+ entity_class_name="scenario",
1040
+ name="output_location",
1041
+ description="Full path to the working directory"
1042
+ )
1043
+
1044
+ # Add/update scenario entity
1045
+ output_db.add_or_update_entity(
1046
+ entity_class_name="scenario",
1047
+ name=scenario_name
1048
+ )
1049
+
1050
+ output_db.add_or_update_alternative(name=scenario_name)
1051
+
1052
+ # Convert folder path to database representation
1053
+ value, type_ = to_database(str(output_path))
1054
+
1055
+ # Add/update folder infio
1056
+ output_db.add_or_update_parameter_value(
1057
+ entity_class_name="scenario",
1058
+ entity_byname=(scenario_name,),
1059
+ parameter_definition_name="output_location",
1060
+ alternative_name=scenario_name,
1061
+ value=value,
1062
+ type=type_
1063
+ )
1064
+
1065
+ output_db.add_or_update_parameter_definition(
1066
+ entity_class_name="scenario",
1067
+ name="finish_time",
1068
+ description="Timestamp when the scenario run finished"
1069
+ )
1070
+
1071
+ dt_value = DateTime(datetime.now())
1072
+ value, type_ = to_database(dt_value)
1073
+
1074
+ # Add/update execution time
1075
+ output_db.add_or_update_parameter_value(
1076
+ entity_class_name="scenario",
1077
+ entity_byname=(scenario_name,),
1078
+ parameter_definition_name="finish_time",
1079
+ alternative_name=scenario_name,
1080
+ value=value,
1081
+ type=type_
1082
+ )
1083
+
1084
+ try:
1085
+ output_db.commit_session("Added/updated scenario information")
1086
+ except NothingToCommit:
1087
+ pass
1088
+
1089
+
1090
+
1091
+ # Debug flag
1092
+ DEBUG = False # Set via environment variable or config
1093
+
1094
+ if __name__ == '__main__':
1095
+ run_tool(main)