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,1001 @@
1
+ """Solve-level configuration loaded from a SpineDB scenario.
2
+
3
+ Architecture notes
4
+ ------------------
5
+
6
+ * Every downstream module reads ``state.solve.<dict>`` keys produced
7
+ here; the keys must match the canonical names (including the
8
+ ``np.str_`` leak through Spine ``Map.indexes``, the stringified-
9
+ float ``rolling_times`` entries, and the lockstep mutation in
10
+ :meth:`SolveConfig.duplicate_solve`).
11
+ * The factory uses :class:`spinedb_api.DatabaseMapping` directly.
12
+ * Reads ``solve``, ``model``, ``unit`` parameter classes only.
13
+ Loading order matters (``make_roll_counter`` →
14
+ ``get_period_timesets`` → 4× ``periods_to_tuples``) because each
15
+ may call :meth:`duplicate_solve`, which mutates 19 sibling
16
+ defaultdicts in lockstep.
17
+ * The DB schema is assumed to be v50+. v50 moved
18
+ ``new_stepduration`` from ``timeset`` to ``solve``;
19
+ ``update_flextool/db_migration.py`` handles upgrades for older DBs
20
+ before this loader runs.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from collections import defaultdict
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ import spinedb_api as api
31
+
32
+ from flextool.engine_polars._solve_state import FlexToolConfigError
33
+
34
+ if TYPE_CHECKING:
35
+ from spinedb_api import DatabaseMapping
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # DB-reader helpers used by the solve-config factory.
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ class DictMode(Enum):
44
+ """Output container shape for :func:`params_to_dict`."""
45
+
46
+ DICT = "dict"
47
+ DEFAULTDICT = "defaultdict"
48
+ LIST = "list"
49
+
50
+
51
+ def get_single_entities(db: "DatabaseMapping", entity_class_name: str) -> list[str]:
52
+ """Return entity names for a single-dimension entity class."""
53
+ return [
54
+ entity["entity_byname"][0]
55
+ for entity in db.find_entities(entity_class_name=entity_class_name)
56
+ ]
57
+
58
+
59
+ def params_to_dict(
60
+ db: "DatabaseMapping",
61
+ cl: str,
62
+ par: str,
63
+ mode: DictMode,
64
+ str_to_list: bool = False,
65
+ ) -> dict | defaultdict | list:
66
+ """Read parameter values of *par* on entity class *cl*.
67
+
68
+ Value-type dispatch:
69
+
70
+ * Map of float → list of (index, float) tuples.
71
+ * Map of str → list of (index, str) tuples.
72
+ * Map of Map → :func:`spinedb_api.convert_map_to_table` flattening.
73
+ * Array → ``param_value.values`` (the raw numpy-string-typed list).
74
+ * Float scalar → :class:`str` of the float (preserved verbatim).
75
+ * String scalar → either the bare string or ``[string]`` when
76
+ *str_to_list* is set.
77
+ """
78
+ all_params = db.find_parameter_values(
79
+ entity_class_name=cl, parameter_definition_name=par
80
+ )
81
+ result: dict | defaultdict | list
82
+ if mode == DictMode.DEFAULTDICT:
83
+ result = defaultdict(list)
84
+ elif mode == DictMode.DICT:
85
+ result = dict()
86
+ elif mode == DictMode.LIST:
87
+ result = []
88
+ else: # pragma: no cover — exhaustive enum
89
+ raise ValueError(f"Unknown DictMode: {mode!r}")
90
+ for param in all_params:
91
+ param_value = api.from_database(param["value"], param["type"])
92
+ if mode in (DictMode.DEFAULTDICT, DictMode.DICT):
93
+ if isinstance(param_value, api.Map):
94
+ if isinstance(param_value.values[0], float):
95
+ result[param["entity_name"]] = list(
96
+ zip(
97
+ list(param_value.indexes),
98
+ list(map(float, param_value.values)),
99
+ )
100
+ )
101
+ elif isinstance(param_value.values[0], str):
102
+ result[param["entity_name"]] = list(
103
+ zip(list(param_value.indexes), param_value.values)
104
+ )
105
+ elif isinstance(param_value.values[0], api.Map):
106
+ result[param["entity_name"]] = api.convert_map_to_table(
107
+ param_value
108
+ )
109
+ else:
110
+ raise TypeError(
111
+ "params_to_dict function does not handle other "
112
+ "values than floats and strings"
113
+ )
114
+ elif isinstance(param_value, api.Array):
115
+ result[param["entity_name"]] = param_value.values
116
+ elif isinstance(param_value, float):
117
+ result[param["entity_name"]] = str(param_value)
118
+ elif isinstance(param_value, str):
119
+ if str_to_list:
120
+ result[param["entity_name"]] = [param_value]
121
+ else:
122
+ result[param["entity_name"]] = param_value
123
+ elif mode == DictMode.LIST:
124
+ if isinstance(param_value, (float, str)):
125
+ result.append([param["entity_name"], param_value]) # type: ignore[union-attr]
126
+ return result
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Solver-config dataclasses
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ @dataclass
135
+ class HiGHSConfig:
136
+ """HiGHS solver option overrides — solve-level dispatch.
137
+
138
+ Each field is keyed by solve name; values are the raw strings stored
139
+ in Spine (e.g. ``"on"`` / ``"off"`` / ``"choose"``). ``HiGHSProblem``
140
+ converts them at solve time.
141
+ """
142
+
143
+ presolve: dict[str, str]
144
+ method: dict[str, str]
145
+ parallel: dict[str, str]
146
+
147
+
148
+ @dataclass
149
+ class SolverSettings:
150
+ """Solver selection + invocation settings, keyed by solve name.
151
+
152
+ *arguments* is the per-solve HiGHS option overrides Map authored
153
+ on the ``solver_arguments`` parameter (1d-map of HiGHS option name
154
+ → value). Read into the effective-options resolver via
155
+ :func:`flextool.engine_polars._solver_dispatch._resolve_effective_highs_options`
156
+ where it is overlaid on top of ``solver_config/highs.opt`` and
157
+ below the CLI overrides. Empty when no solve authored an entry.
158
+ """
159
+
160
+ solvers: dict[str, str]
161
+ precommand: dict[str, str]
162
+ arguments: dict[str, dict[str, str]]
163
+
164
+
165
+ @dataclass
166
+ class SolverConfig:
167
+ """Per-solve multi-solver dispatch configuration (v52 schema).
168
+
169
+ Holds the seven solver-selection parameters introduced by the
170
+ v52 migration (see ``specs/flextool-multi-solver-handoff.md``
171
+ Step 1). Defaults match the v52 parameter definition defaults so
172
+ a solve that does not author any ``solver_*`` parameter still
173
+ constructs a meaningful :class:`SolverConfig` (HiGHS via the
174
+ direct in-process API, no convenience knobs set, "normal" log).
175
+
176
+ Fields
177
+ ------
178
+ name
179
+ Solver name; one of polar-high's ``available_solvers``
180
+ ("highs", "gurobi", "cplex", "xpress", "copt"). Default
181
+ ``"highs"``.
182
+ io_api
183
+ "direct" (in-process binding, fastest), "mps" or "lp" (file
184
+ fallback). Default ``"direct"``.
185
+ options
186
+ Free-form key→value dict forwarded raw to the solver. Empty
187
+ by default; user populates via the ``solver_arguments``
188
+ 1d-map parameter (Batch C.2: the legacy ``solver_options``
189
+ Map was folded into ``solver_arguments`` and removed).
190
+ time_limit
191
+ Wall-clock seconds; ``None`` means no limit. Translated to
192
+ each solver's native parameter name by
193
+ :func:`flextool.engine_polars._solver_dispatch.build_solver_options`.
194
+ mip_gap
195
+ Relative MIP gap; ``None`` means solver default.
196
+ threads
197
+ Worker thread cap; ``None`` means solver default.
198
+ log_level
199
+ ``"silent"`` / ``"normal"`` / ``"verbose"``. Default
200
+ ``"normal"``.
201
+ """
202
+
203
+ name: str = "highs"
204
+ io_api: str = "direct"
205
+ options: dict[str, Any] = field(default_factory=dict)
206
+ time_limit: float | None = None
207
+ mip_gap: float | None = None
208
+ threads: int | None = None
209
+ log_level: str = "normal"
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # SolveConfig — main container
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ class SolveConfig:
218
+ """All solve-level parameters and mutable tracking state.
219
+
220
+ See ``audit/solve_orchestration_plan.md §1.3`` for the per-field
221
+ contract; downstream modules read these dicts directly so the
222
+ container shape (``defaultdict(list)`` vs plain ``dict``), the
223
+ keys, and the value types must match flextool exactly.
224
+ """
225
+
226
+ def __init__(
227
+ self,
228
+ model: list,
229
+ model_solve: defaultdict,
230
+ solve_modes: dict,
231
+ rolling_times: defaultdict,
232
+ highs: HiGHSConfig,
233
+ solver_settings: SolverSettings,
234
+ solve_period_years_represented: defaultdict,
235
+ hole_multipliers: defaultdict,
236
+ contains_solves: defaultdict,
237
+ stochastic_branches: defaultdict,
238
+ periods_available: dict,
239
+ delay_durations: dict,
240
+ logger: logging.Logger,
241
+ use_row_scaling: dict | None = None,
242
+ scale_the_objective: dict | None = None,
243
+ user_bound_scale: dict | None = None,
244
+ solver_configs: dict[str, "SolverConfig"] | None = None,
245
+ decomposition: dict | None = None,
246
+ benders_max_iter: dict | None = None,
247
+ benders_tolerance: dict | None = None,
248
+ benders_in_out_weight: dict | None = None,
249
+ scaling: dict | None = None,
250
+ ) -> None:
251
+ # Base fields (read directly from DB in load_from_db).
252
+ self.model = model
253
+ self.model_solve = model_solve
254
+ self.solve_modes = solve_modes
255
+ self.rolling_times = rolling_times
256
+ self.highs = highs
257
+ self.solver_settings = solver_settings
258
+ self.solve_period_years_represented = solve_period_years_represented
259
+ self.hole_multipliers = hole_multipliers
260
+ self.contains_solves = contains_solves
261
+ self.stochastic_branches = stochastic_branches
262
+ self.periods_available = periods_available
263
+ self.delay_durations = delay_durations
264
+ self.logger = logger
265
+ # solve-name → "yes"/"no" string from the DB (default off everywhere).
266
+ self.use_row_scaling: dict = (
267
+ use_row_scaling if use_row_scaling is not None else {}
268
+ )
269
+ self.scale_the_objective: dict = (
270
+ scale_the_objective if scale_the_objective is not None else {}
271
+ )
272
+ # solve-name → integer user_bound_scale override. When set, overrides
273
+ # polar-high's stream-time auto-pick
274
+ # (``polar_high.engine._recommend_user_bound_scale``). Pass the
275
+ # value HiGHS recommends in its "user-scaled problem has some
276
+ # excessively large row bounds — Consider setting the user_bound_scale
277
+ # option to <N>" warning for the most reliable result.
278
+ self.user_bound_scale: dict = (
279
+ user_bound_scale if user_bound_scale is not None else {}
280
+ )
281
+ # v52 multi-solver dispatch — solve-name → :class:`SolverConfig`.
282
+ # Empty when no ``solver_*`` parameters are authored on any solve;
283
+ # callers fall back to ``SolverConfig()`` defaults (HiGHS/direct).
284
+ self.solver_configs: dict[str, SolverConfig] = (
285
+ solver_configs if solver_configs is not None else {}
286
+ )
287
+
288
+ # v60/v62 per-solve decomposition. ``decomposition`` maps
289
+ # solve-name → "none"/"benders"; absent means the schema
290
+ # default "none" (monolithic). The two ``benders_*`` dicts carry
291
+ # the per-solve Benders knobs (str(float) values, only present
292
+ # when authored); absence falls back to the schema defaults via
293
+ # :meth:`benders_config_for`.
294
+ self.decomposition: dict = (
295
+ decomposition if decomposition is not None else {}
296
+ )
297
+ self.benders_max_iter: dict = (
298
+ benders_max_iter if benders_max_iter is not None else {}
299
+ )
300
+ self.benders_tolerance: dict = (
301
+ benders_tolerance if benders_tolerance is not None else {}
302
+ )
303
+ self.benders_in_out_weight: dict = (
304
+ benders_in_out_weight if benders_in_out_weight is not None else {}
305
+ )
306
+ # v64 per-solve autoscaler mode (solve-name -> "off"/"solver_only"/
307
+ # "basic"/"full"); only authored solves appear. Resolved at access
308
+ # time via :meth:`scaling_for` (absent -> None -> CLI/env/default).
309
+ self.scaling: dict = (
310
+ scaling if scaling is not None else {}
311
+ )
312
+
313
+ # Computed fields — populated by load_from_db after construction.
314
+ self.roll_counter: dict[str, int] = {}
315
+ self.timesets_used_by_solves: defaultdict = defaultdict(list)
316
+ self.invest_periods: defaultdict = defaultdict(list)
317
+ self.realized_periods: defaultdict = defaultdict(list)
318
+ self.realized_invest_periods: defaultdict = defaultdict(list)
319
+ self.fix_storage_periods: defaultdict = defaultdict(list)
320
+
321
+ # Mutable tracking — populated during the recursive solve loop.
322
+ self.real_solves: list[str] = []
323
+ self.first_of_complete_solve: list[str] = []
324
+ self.last_of_solve: list[str] = []
325
+
326
+ # ------------------------------------------------------------------
327
+ # Factories
328
+ # ------------------------------------------------------------------
329
+
330
+ @classmethod
331
+ def load_from_db(
332
+ cls, db: "DatabaseMapping", logger: logging.Logger
333
+ ) -> "SolveConfig":
334
+ """Read all solve-level parameters from *db* into a SolveConfig.
335
+
336
+ Loading order is preserved exactly — see the four-step block at
337
+ the bottom of this method.
338
+
339
+ 1. Basic params (model, solvers, rolling_times, …)
340
+ 2. ``make_roll_counter`` (needs ``solve_modes``).
341
+ 3. ``get_period_timesets`` (needs ``model_solve`` +
342
+ ``contains_solves``; may call ``duplicate_solve``).
343
+ 4. Four ``periods_to_tuples`` calls
344
+ (``invest_periods``, ``realized_periods``,
345
+ ``realized_invest_periods``, ``fix_storage_periods``;
346
+ may call ``duplicate_solve`` for 2D-Map values).
347
+ """
348
+ model = get_single_entities(db=db, entity_class_name="model")
349
+ model_solve: defaultdict = params_to_dict(
350
+ db=db, cl="model", par="solves", mode=DictMode.DEFAULTDICT
351
+ )
352
+ # Auto-wire when no model:solves defined and only one solve exists.
353
+ solves_temp = get_single_entities(db=db, entity_class_name="solve")
354
+ if len(model_solve) == 0 and len(solves_temp) == 1:
355
+ model_solve["flextool"] = [solves_temp[0]]
356
+
357
+ solve_modes: dict = params_to_dict(
358
+ db=db, cl="solve", par="solve_mode", mode=DictMode.DICT
359
+ )
360
+ # Batch C.5 — ``highs_presolve`` shortcut removed; override
361
+ # is now keyed as ``presolve`` inside ``solver_arguments``.
362
+ highs_presolve: dict = {}
363
+ # Batch C.3 — ``highs_method`` shortcut removed; the equivalent
364
+ # override is now keyed as ``solver`` inside
365
+ # ``solver_arguments``.
366
+ highs_method: dict = {}
367
+ # Batch C.4 — ``highs_parallel`` shortcut removed; override
368
+ # is now keyed as ``parallel`` inside ``solver_arguments``.
369
+ highs_parallel: dict = {}
370
+ solve_period_years_represented: defaultdict = params_to_dict(
371
+ db=db,
372
+ cl="solve",
373
+ par="years_represented",
374
+ mode=DictMode.DEFAULTDICT,
375
+ )
376
+ solvers: dict = params_to_dict(
377
+ db=db, cl="solve", par="solver", mode=DictMode.DICT
378
+ )
379
+ solver_precommand: dict = params_to_dict(
380
+ db=db, cl="solve", par="solver_precommand", mode=DictMode.DICT
381
+ )
382
+ # Batch C.1 — ``solver_arguments`` retyped from array to 1d-map
383
+ # at v56 schema; read directly via the Spine API the same way
384
+ # ``solver_options`` is read below so we get a dict-of-dicts
385
+ # (solve name → option-key → value) ready for the
386
+ # effective-options resolver in ``_solver_dispatch``.
387
+ solver_arguments: dict[str, dict[str, str]] = {}
388
+ for param in db.find_parameter_values(
389
+ entity_class_name="solve", parameter_definition_name="solver_arguments"
390
+ ):
391
+ pv = api.from_database(param["value"], param["type"])
392
+ if isinstance(pv, api.Map):
393
+ solver_arguments[param["entity_name"]] = {
394
+ str(k): str(v)
395
+ for k, v in zip(list(pv.indexes), list(pv.values))
396
+ }
397
+ elif pv is None:
398
+ continue
399
+ else:
400
+ logger.warning(
401
+ "solve.%s.solver_arguments is not a 1d-map (%r) — "
402
+ "ignoring; expected a Map of HiGHS option name -> value",
403
+ param["entity_name"],
404
+ type(pv).__name__,
405
+ )
406
+ # v52 multi-solver dispatch params. Each per-solve value is
407
+ # rolled up into one :class:`SolverConfig` keyed by solve name
408
+ # (see ``specs/flextool-multi-solver-handoff.md`` Steps 1-3).
409
+ # The seven params are read individually here and aggregated
410
+ # into ``solver_configs`` below. ``solver_options`` is a Map of
411
+ # str→Any forwarded raw to the chosen solver; everything else is
412
+ # a scalar default-None convenience knob (None means "no override").
413
+ #
414
+ # NOTE: ``solver`` is also read via the older ``solvers``
415
+ # variable above for the legacy :class:`SolverSettings` dataclass
416
+ # (which downstream callers still consume); we reuse that dict
417
+ # here rather than re-querying.
418
+ # Batch C.9 — ``solver_io_api`` DB axis removed. Replaced by
419
+ # the ``--matrix-file-format`` CLI flag (mps | lp),
420
+ # env-var-plumbed via ``FLEXTOOL_MATRIX_FILE_FORMAT``. The
421
+ # in-process vs. file dispatch is implicit:
422
+ # * HiGHS + no --save-memory: ``SolverConfig.io_api`` defaults
423
+ # to ``"direct"`` (in-process binding).
424
+ # * HiGHS + --save-memory: ``Problem.solve(save_memory=True)``
425
+ # round-trips through MPS internally; ``io_api`` is ignored
426
+ # on that path.
427
+ # * Commercial solver: ``polar_high.solvers.solve`` consults
428
+ # ``io_api``. When the CLI flag is set its value
429
+ # (``"mps"`` or ``"lp"``) applies uniformly to every solve.
430
+ # ``matrix_file_format`` stays an empty dict (no per-solve
431
+ # author override since the DB axis is gone); the default is
432
+ # resolved below using the env var when present, else
433
+ # ``"direct"``.
434
+ import os as _os_c9
435
+ _cli_io_api = _os_c9.environ.get("FLEXTOOL_MATRIX_FILE_FORMAT")
436
+ matrix_file_format: dict = {}
437
+ # Batch C.7 — ``solver_log_level`` shortcut removed; use the
438
+ # --solver-log-level CLI flag (silent / normal / verbose →
439
+ # HiGHS output_flag + log_dev_level).
440
+ solver_log_level: dict = {}
441
+ # solver_mip_gap comes back as the stringified float that
442
+ # ``params_to_dict`` produces for scalar floats (see line
443
+ # ~141). Batches C.6 and C.8 dropped the ``solver_threads``
444
+ # and ``solver_time_limit`` DB axes (use --highs-threads and
445
+ # --solver-time-limit CLI flags instead).
446
+ solver_time_limit_raw: dict = {}
447
+ solver_mip_gap_raw: dict = params_to_dict(
448
+ db=db, cl="solve", par="solver_mip_gap", mode=DictMode.DICT
449
+ )
450
+ solver_threads_raw: dict = {}
451
+ # Batch C.2 — ``solver_options`` was folded into
452
+ # ``solver_arguments`` (now the canonical 1d-map) and the
453
+ # parameter_definition was removed. ``SolverConfig.options``
454
+ # is sourced from ``solver_arguments`` below.
455
+ stochastic_branches: defaultdict = params_to_dict(
456
+ db=db,
457
+ cl="solve",
458
+ par="stochastic_branches",
459
+ mode=DictMode.DEFAULTDICT,
460
+ )
461
+ contains_solves: defaultdict = params_to_dict(
462
+ db=db,
463
+ cl="solve",
464
+ par="contains_solves",
465
+ mode=DictMode.DEFAULTDICT,
466
+ str_to_list=True,
467
+ )
468
+ hole_multipliers: defaultdict = params_to_dict(
469
+ db=db,
470
+ cl="solve",
471
+ par="timeline_hole_multiplier",
472
+ mode=DictMode.DEFAULTDICT,
473
+ )
474
+ delay_durations: dict = params_to_dict(
475
+ db=db, cl="unit", par="delay", mode=DictMode.DICT
476
+ )
477
+ periods_available: dict = params_to_dict(
478
+ db=db, cl="model", par="periods_available", mode=DictMode.DICT
479
+ )
480
+ # Per-solve opt-in for automatic LP-row scaling. Default
481
+ # absent / "no" leaves AMPL behaviour as pre-Agent-5; the
482
+ # native engine consumes this flag during preprocessing the
483
+ # same way.
484
+ # Batch C.10 — DB-level ``use_row_scaling`` removed; use the
485
+ # --scaling CLI flag (autoscale; off/solver_only/basic/full).
486
+ # The per-solve dict is hard-wired to {} so every solve
487
+ # emits p_use_row_scaling=0 (the
488
+ # ``use_row_scaling.get(solve, "no")`` default branch in
489
+ # _emit_solve_writers.derive_p_use_row_scaling), preserving
490
+ # the Mode A pre-scaling behaviour for the row-scaling
491
+ # capacity-proxy emitter. The autoscaler's Layer 2 + Layer
492
+ # 3 (driven by --scaling) are unaffected.
493
+ use_row_scaling: dict = {}
494
+ scale_the_objective: dict = params_to_dict(
495
+ db=db, cl="solve", par="scale_the_objective", mode=DictMode.DICT
496
+ )
497
+ user_bound_scale: dict = params_to_dict(
498
+ db=db, cl="solve", par="user_bound_scale", mode=DictMode.DICT
499
+ )
500
+
501
+ # v60/v62 per-solve decomposition scheme + Benders knobs. Only
502
+ # solves that explicitly author the parameter appear in each
503
+ # dict; absent solves fall back to the schema defaults
504
+ # (decomposition "none"; max_iter 50 / tol 1e-3) at access time
505
+ # via ``decomposition_for`` / ``benders_config_for``.
506
+ decomposition: dict = params_to_dict(
507
+ db=db, cl="solve", par="decomposition", mode=DictMode.DICT
508
+ )
509
+ benders_max_iter: dict = params_to_dict(
510
+ db=db, cl="solve", par="benders_max_iter", mode=DictMode.DICT
511
+ )
512
+ benders_tolerance: dict = params_to_dict(
513
+ db=db, cl="solve", par="benders_tolerance", mode=DictMode.DICT
514
+ )
515
+ benders_in_out_weight: dict = params_to_dict(
516
+ db=db, cl="solve", par="benders_in_out_weight", mode=DictMode.DICT
517
+ )
518
+
519
+ # v64 per-solve autoscaler mode. Only solves that explicitly
520
+ # author solve.scaling appear; absent solves resolve to None via
521
+ # ``scaling_for`` (caller falls back to CLI/env/default "full").
522
+ scaling: dict = params_to_dict(
523
+ db=db, cl="solve", par="scaling", mode=DictMode.DICT
524
+ )
525
+
526
+ # rolling_times: assemble per-solve [jump, horizon, duration].
527
+ rolling_duration: dict = params_to_dict(
528
+ db=db, cl="solve", par="rolling_duration", mode=DictMode.DICT
529
+ )
530
+ rolling_solve_horizon: dict = params_to_dict(
531
+ db=db, cl="solve", par="rolling_solve_horizon", mode=DictMode.DICT
532
+ )
533
+ rolling_solve_jump: dict = params_to_dict(
534
+ db=db, cl="solve", par="rolling_solve_jump", mode=DictMode.DICT
535
+ )
536
+ all_keys = (
537
+ set(rolling_duration)
538
+ | set(rolling_solve_horizon)
539
+ | set(rolling_solve_jump)
540
+ )
541
+ rolling_times: defaultdict = defaultdict(
542
+ list,
543
+ {
544
+ key: [
545
+ rolling_solve_jump.get(key, 0),
546
+ rolling_solve_horizon.get(key, 0),
547
+ rolling_duration.get(key, -1),
548
+ ]
549
+ for key in all_keys
550
+ },
551
+ )
552
+
553
+ highs = HiGHSConfig(
554
+ presolve=highs_presolve,
555
+ method=highs_method,
556
+ parallel=highs_parallel,
557
+ )
558
+ solver_settings = SolverSettings(
559
+ solvers=solvers,
560
+ precommand=solver_precommand,
561
+ arguments=solver_arguments,
562
+ )
563
+
564
+ # Roll the seven v52 per-solve param dicts into a single
565
+ # ``solver_configs[solve_name] -> SolverConfig`` mapping. Keys
566
+ # are the union of solve names appearing across any of the
567
+ # seven dicts — a solve that authors *any* solver_* param gets
568
+ # an explicit entry; solves with no override remain absent and
569
+ # callers fall back to ``SolverConfig()`` defaults.
570
+ # Batch C.2 — ``solver_options`` removed; the per-solve free-form
571
+ # option dict for the commercial-solver path is sourced from
572
+ # ``solver_arguments`` (the same 1d-map the HiGHS-side resolver
573
+ # consumes).
574
+ solver_config_keys = (
575
+ set(solvers)
576
+ | set(matrix_file_format)
577
+ | set(solver_arguments)
578
+ | set(solver_time_limit_raw)
579
+ | set(solver_mip_gap_raw)
580
+ | set(solver_threads_raw)
581
+ | set(solver_log_level)
582
+ )
583
+
584
+ def _opt_float(raw: dict, key: str) -> float | None:
585
+ v = raw.get(key)
586
+ return float(v) if v is not None else None
587
+
588
+ def _opt_int(raw: dict, key: str) -> int | None:
589
+ v = raw.get(key)
590
+ return int(float(v)) if v is not None else None
591
+
592
+ # Batch C.9 — the CLI ``--matrix-file-format`` env var override
593
+ # (when set, ``"mps"`` or ``"lp"``) becomes the default
594
+ # ``SolverConfig.io_api`` for every solve. Otherwise the
595
+ # default is ``"direct"`` (HiGHS in-process binding; commercial
596
+ # solvers' in-process Python API).
597
+ _io_api_default = _cli_io_api if _cli_io_api in ("mps", "lp") else "direct"
598
+ solver_configs: dict[str, SolverConfig] = {}
599
+ for key in solver_config_keys:
600
+ solver_configs[key] = SolverConfig(
601
+ name=solvers.get(key, "highs"),
602
+ io_api=matrix_file_format.get(key, _io_api_default),
603
+ options=dict(solver_arguments.get(key, {})),
604
+ time_limit=_opt_float(solver_time_limit_raw, key),
605
+ mip_gap=_opt_float(solver_mip_gap_raw, key),
606
+ threads=_opt_int(solver_threads_raw, key),
607
+ log_level=solver_log_level.get(key, "normal"),
608
+ )
609
+
610
+ obj = cls(
611
+ model=model,
612
+ model_solve=model_solve,
613
+ solve_modes=solve_modes,
614
+ rolling_times=rolling_times,
615
+ highs=highs,
616
+ solver_settings=solver_settings,
617
+ solve_period_years_represented=solve_period_years_represented,
618
+ hole_multipliers=hole_multipliers,
619
+ contains_solves=contains_solves,
620
+ stochastic_branches=stochastic_branches,
621
+ periods_available=periods_available,
622
+ delay_durations=delay_durations,
623
+ logger=logger,
624
+ use_row_scaling=use_row_scaling,
625
+ scale_the_objective=scale_the_objective,
626
+ user_bound_scale=user_bound_scale,
627
+ solver_configs=solver_configs,
628
+ decomposition=decomposition,
629
+ benders_max_iter=benders_max_iter,
630
+ benders_tolerance=benders_tolerance,
631
+ benders_in_out_weight=benders_in_out_weight,
632
+ scaling=scaling,
633
+ )
634
+
635
+ # Computed fields — loading order MUST be preserved exactly.
636
+ # ``duplicate_solve`` mutates 19 sibling dicts in lockstep, so
637
+ # any reordering desyncs them and downstream reads silently
638
+ # produce empty/zero results.
639
+ obj.roll_counter = obj.make_roll_counter()
640
+ obj.timesets_used_by_solves = obj.get_period_timesets(db=db)
641
+ obj.invest_periods = obj.periods_to_tuples(
642
+ db=db, cl="solve", par="invest_periods"
643
+ )
644
+ obj.realized_periods = obj.periods_to_tuples(
645
+ db=db, cl="solve", par="realized_periods"
646
+ )
647
+ obj.realized_invest_periods = obj.periods_to_tuples(
648
+ db=db, cl="solve", par="realized_invest_periods"
649
+ )
650
+ obj.fix_storage_periods = obj.periods_to_tuples(
651
+ db=db, cl="solve", par="fix_storage_periods"
652
+ )
653
+
654
+ return obj
655
+
656
+ @classmethod
657
+ def load_from_db_url(
658
+ cls,
659
+ db_url: str,
660
+ scenario: str,
661
+ logger: logging.Logger | None = None,
662
+ ) -> "SolveConfig":
663
+ """Convenience factory: open *db_url*, apply *scenario*, load.
664
+
665
+ Builds a short-lived :class:`spinedb_api.DatabaseMapping` with the
666
+ scenario filter applied, calls :meth:`load_from_db`, and closes
667
+ the DB. Useful for tests / CLI entry points that don't already
668
+ own a session.
669
+ """
670
+ from spinedb_api import DatabaseMapping
671
+ from spinedb_api.filters.scenario_filter import (
672
+ apply_scenario_filter_to_subqueries,
673
+ )
674
+
675
+ if logger is None:
676
+ logger = logging.getLogger(f"flextool.engine_polars.solve_config[{scenario}]")
677
+ url = str(db_url)
678
+ if "://" not in url:
679
+ url = f"sqlite:///{url}"
680
+ with DatabaseMapping(url) as db:
681
+ apply_scenario_filter_to_subqueries(db, scenario)
682
+ # Pre-warm the entity + parameter_value caches so the
683
+ # ``find_entities`` / ``find_parameter_values`` calls inside
684
+ # ``load_from_db`` (get_period_timesets, get_single_param, …)
685
+ # hit memory rather than running a SQL round-trip each.
686
+ # Pre-fetch at construction and share the DB context across
687
+ # the whole pipeline. Measured 5.5-6.4× speedup on large
688
+ # customer DBs (H2_trade ≈ 13 MB, 2.5 s → 0.35 s).
689
+ db.fetch_all("entity")
690
+ db.fetch_all("parameter_value")
691
+ return cls.load_from_db(db, logger)
692
+
693
+ @classmethod
694
+ def load_from_source(
695
+ cls,
696
+ source: object,
697
+ logger: logging.Logger | None = None,
698
+ ) -> "SolveConfig":
699
+ """Load via the :class:`InputSource` Protocol.
700
+
701
+ Currently only :class:`flextool.engine_polars._spinedb_reader.SpineDbReader`
702
+ sources are supported — they expose the underlying ``db_url`` and
703
+ ``scenario`` so the canonical :meth:`load_from_db_url` path can
704
+ be reused. In-memory and CSV-backed sources for solve-class
705
+ parameters are out of Γ.8.A scope; Γ.8.D wires those once the
706
+ chain.run_chain integration needs them.
707
+ """
708
+ # Late import: SpineDbReader brings spinedb_api into the import
709
+ # graph, but we only need it for the isinstance check.
710
+ from flextool.engine_polars._spinedb_reader import SpineDbReader
711
+
712
+ if isinstance(source, SpineDbReader):
713
+ return cls.load_from_db_url(
714
+ source.db_url, source.scenario, logger=logger
715
+ )
716
+ raise NotImplementedError(
717
+ f"SolveConfig.load_from_source does not yet support "
718
+ f"{type(source).__name__!r} sources. Use load_from_db / "
719
+ f"load_from_db_url with a Spine DB for now; the in-memory and "
720
+ f"CSV adapters land in Gamma.8.D when chain.run_chain is rewired."
721
+ )
722
+
723
+ # ------------------------------------------------------------------
724
+ # Methods
725
+ # ------------------------------------------------------------------
726
+
727
+ def make_roll_counter(self) -> dict[str, int]:
728
+ """Return a roll counter initialised to 0 for every rolling-window
729
+ solve. Single-solve mode entries are intentionally absent from
730
+ the returned dict (NOT zero) so callers can distinguish ``not in``
731
+ from ``== 0`` semantics.
732
+ """
733
+ roll_counter_map: dict[str, int] = {}
734
+ for key, mode in list(self.solve_modes.items()):
735
+ if mode == "rolling_window":
736
+ roll_counter_map[key] = 0
737
+ return roll_counter_map
738
+
739
+ def get_period_timesets(self, db: "DatabaseMapping") -> defaultdict:
740
+ """Read ``period_timeset`` parameters for every active solve.
741
+
742
+ May call :meth:`duplicate_solve` when a solve carries a
743
+ 2D-Map-shaped ``period_timeset`` parameter (one input solve fans
744
+ out into one solve per outer-Map key).
745
+ """
746
+ entities = db.find_entities(entity_class_name="solve")
747
+ params = db.find_parameter_values(
748
+ entity_class_name="solve",
749
+ parameter_definition_name="period_timeset",
750
+ )
751
+ timesets_used_by_solves: defaultdict = defaultdict(list)
752
+ solves_in_model = [
753
+ item
754
+ for sublist in (
755
+ list(self.model_solve.values())
756
+ + list(self.contains_solves.values())
757
+ )
758
+ for item in sublist
759
+ ]
760
+ for entity in entities:
761
+ if entity["name"] not in solves_in_model:
762
+ continue
763
+ for param in params:
764
+ if param["entity_name"] != entity["name"]:
765
+ continue
766
+ param_value = api.from_database(param["value"], param["type"])
767
+ for i, _row in enumerate(param_value.indexes):
768
+ if isinstance(param_value.values[i], api.Map):
769
+ new_name = (
770
+ param["entity_name"] + "_" + param_value.indexes[i]
771
+ )
772
+ self.duplicate_solve(param["entity_name"], new_name)
773
+ timesets_used_by_solves[new_name].append(
774
+ (
775
+ param_value.values[i].indexes[i],
776
+ param_value.values[i].values[i],
777
+ )
778
+ )
779
+ else:
780
+ timesets_used_by_solves[param["entity_name"]].append(
781
+ (
782
+ param_value.indexes[i],
783
+ param_value.values[i],
784
+ )
785
+ )
786
+ return timesets_used_by_solves
787
+
788
+ def duplicate_solve(
789
+ self,
790
+ old_solve: str,
791
+ new_name: str,
792
+ update_model_solves: bool = True,
793
+ ) -> None:
794
+ """Duplicate every solve-level dict entry from *old_solve* under
795
+ *new_name*.
796
+
797
+ Mutates 19 sibling defaultdicts (and ``model_solve`` when
798
+ *update_model_solves* is set) so downstream readers can address
799
+ the duplicated solve transparently.
800
+
801
+ ``update_model_solves=False`` is used by the rolling builder
802
+ (Γ.8.C) where roll-named sub-solves should NOT replace their
803
+ parent in ``model_solve``.
804
+ """
805
+ if (
806
+ new_name not in self.model_solve.values()
807
+ and new_name not in self.contains_solves.values()
808
+ ):
809
+ dup_map_list = [
810
+ self.solve_modes,
811
+ self.roll_counter,
812
+ self.highs.presolve,
813
+ self.highs.method,
814
+ self.highs.parallel,
815
+ self.solve_period_years_represented,
816
+ self.solver_settings.solvers,
817
+ self.solver_settings.precommand,
818
+ self.solver_settings.arguments,
819
+ self.contains_solves,
820
+ self.rolling_times,
821
+ self.realized_periods,
822
+ self.realized_invest_periods,
823
+ self.invest_periods,
824
+ self.fix_storage_periods,
825
+ self.decomposition,
826
+ self.benders_max_iter,
827
+ self.benders_tolerance,
828
+ self.benders_in_out_weight,
829
+ self.scaling,
830
+ ]
831
+ for dup_map in dup_map_list:
832
+ if old_solve in dup_map.keys():
833
+ dup_map[new_name] = dup_map[old_solve]
834
+ if update_model_solves:
835
+ for model, solves in list(self.model_solve.items()):
836
+ if old_solve in solves:
837
+ solves.remove(old_solve)
838
+ if new_name not in solves:
839
+ solves.append(new_name)
840
+ self.model_solve[model] = solves
841
+
842
+ # ------------------------------------------------------------------
843
+ # v60/v62 per-solve decomposition accessors
844
+ # ------------------------------------------------------------------
845
+
846
+ def decomposition_for(self, solve_name: str) -> str:
847
+ """Resolve the decomposition scheme for *solve_name*.
848
+
849
+ Returns ``"benders"`` only when the solve explicitly authors
850
+ ``solve.decomposition = benders``; everything else (unset, the
851
+ schema default ``"none"``, blank, or any unrecognised value)
852
+ resolves to ``"none"`` (monolithic). Recognised values are
853
+ normalised to lower-case so authoring case does not matter.
854
+ """
855
+ raw = self.decomposition.get(solve_name)
856
+ if raw is None:
857
+ return "none"
858
+ value = str(raw).strip().lower()
859
+ return "benders" if value == "benders" else "none"
860
+
861
+ def scaling_for(self, solve_name: str) -> str | None:
862
+ """Resolve the autoscaler scaling mode authored on *solve_name*.
863
+
864
+ Returns the normalised lower-case mode string (one of
865
+ ``off`` / ``solver_only`` / ``basic`` / ``full``) when the solve
866
+ explicitly authors ``solve.scaling``; returns ``None`` when the
867
+ parameter is absent, blank, or unrecognised so the caller falls
868
+ back to the run-time ``--scaling`` / ``FLEXTOOL_SCALING``
869
+ override or the engine default (``full``). Authoring case does
870
+ not matter.
871
+ """
872
+ raw = self.scaling.get(solve_name)
873
+ if raw is None:
874
+ return None
875
+ value = str(raw).strip().lower()
876
+ return value if value in (
877
+ "off", "solver_only", "basic", "full"
878
+ ) else None
879
+
880
+ def benders_config_for(self, solve_name: str) -> tuple[int, float, float]:
881
+ """Resolve ``(max_iter, tol, in_out_weight)`` for *solve_name*.
882
+
883
+ Falls back to the schema defaults (50 / 1e-3 / 0.0) for any knob
884
+ the solve does not author. ``params_to_dict`` stores scalar
885
+ floats as ``str(float)``, so values are coerced through ``float``
886
+ here; ``max_iter`` is additionally rounded to ``int``. The
887
+ ``in_out_weight`` (default 0.0 = OFF = exact Benders) is the DB
888
+ value; the machine-local ``FLEXTOOL_BENDERS_IN_OUT_WEIGHT`` env
889
+ overrides it downstream in
890
+ :func:`flextool.engine_polars._benders._resolve_benders_in_out_weight`.
891
+ """
892
+ max_iter_raw = self.benders_max_iter.get(solve_name)
893
+ tol_raw = self.benders_tolerance.get(solve_name)
894
+ weight_raw = self.benders_in_out_weight.get(solve_name)
895
+ max_iter = int(float(max_iter_raw)) if max_iter_raw is not None else 50
896
+ tol = float(tol_raw) if tol_raw is not None else 1e-3
897
+ in_out_weight = float(weight_raw) if weight_raw is not None else 0.0
898
+ return max_iter, tol, in_out_weight
899
+
900
+ def periods_to_tuples(
901
+ self,
902
+ db: "DatabaseMapping",
903
+ cl: str,
904
+ par: str,
905
+ ) -> defaultdict:
906
+ """Read period-shaped solve parameters as ``[(p_from, p_in), …]``.
907
+
908
+ For 1D Array values (e.g. ``realized_periods=["p2020","p2025"]``)
909
+ each element ``p`` becomes ``(p, p)``.
910
+
911
+ For 2D Map values (e.g. ``invest_periods`` under the
912
+ ``invest_twoYears4Times_5weeks`` scenario where one solve ladders
913
+ an "invest in p2020 covers p2020+p2025" pattern) the outer Map
914
+ triggers :meth:`duplicate_solve` and per-(outer, inner) tuples
915
+ flow into the new solve's entry. Inner shape is required to be
916
+ Map-of-scalars; mixed 1D/2D with the same name raises.
917
+ """
918
+ entities = db.find_entities(entity_class_name=cl)
919
+ params = db.find_parameter_values(
920
+ entity_class_name=cl,
921
+ parameter_definition_name=par,
922
+ )
923
+ result_dict: defaultdict = defaultdict(list)
924
+ for entity in entities:
925
+ for param in params:
926
+ if param["entity_name"] != entity["name"]:
927
+ continue
928
+ param_value = api.from_database(param["value"], param["type"])
929
+ for i, row in enumerate(param_value.values):
930
+ if isinstance(param_value.values[i], api.Map):
931
+ # 2D Map: outer index → inner Map of (p, "yes")-ish.
932
+ for j, _row2 in enumerate(row.values):
933
+ if isinstance(param_value.values[j], api.Map):
934
+ new_name = (
935
+ param["entity_name"]
936
+ + "_"
937
+ + param_value.indexes[i]
938
+ )
939
+ self.duplicate_solve(
940
+ param["entity_name"], new_name
941
+ )
942
+ result_dict[new_name].append(
943
+ (
944
+ param_value.indexes[i],
945
+ param_value.values[i].indexes[j],
946
+ )
947
+ )
948
+ # Re-shape ``timesets_used_by_solves`` for
949
+ # the duplicated solve: keep only entries
950
+ # whose period matches the inner index.
951
+ new_period_timeset_list = []
952
+ for solve, period__timeset_list in list(
953
+ self.timesets_used_by_solves.items()
954
+ ):
955
+ if solve != param["entity_name"]:
956
+ continue
957
+ for period__timeset in period__timeset_list:
958
+ if (
959
+ period__timeset[0]
960
+ == param_value.values[i].indexes[j]
961
+ ):
962
+ new_period_timeset_list.append(
963
+ period__timeset
964
+ )
965
+ if (
966
+ new_name
967
+ not in self.timesets_used_by_solves.keys()
968
+ ):
969
+ self.timesets_used_by_solves[new_name] = (
970
+ new_period_timeset_list
971
+ )
972
+ else:
973
+ for item in new_period_timeset_list:
974
+ if (
975
+ item
976
+ not in self.timesets_used_by_solves[
977
+ new_name
978
+ ]
979
+ ):
980
+ self.timesets_used_by_solves[
981
+ new_name
982
+ ].append(item)
983
+ else:
984
+ raise FlexToolConfigError(
985
+ "periods_to_tuple function handles only "
986
+ f"arrays or 2d maps: {entity}, {param}"
987
+ )
988
+ else:
989
+ result_dict[param["entity_name"]].append((row, row))
990
+ return result_dict
991
+
992
+
993
+ __all__ = [
994
+ "DictMode",
995
+ "HiGHSConfig",
996
+ "SolverConfig",
997
+ "SolverSettings",
998
+ "SolveConfig",
999
+ "get_single_entities",
1000
+ "params_to_dict",
1001
+ ]