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,453 @@
1
+ """Top-level orchestration: ties together data loading, config, and plotting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import pandas as pd
9
+ import yaml
10
+
11
+ from flextool.lean_parquet import write_lean_parquet
12
+
13
+ from flextool.scenario_comparison.config_builder import (
14
+ assign_palette_colors,
15
+ discover_dispatch_entities,
16
+ )
17
+ from flextool.scenario_comparison.data_models import DispatchMappings
18
+ from flextool.scenario_comparison.db_reader import get_scenario_results
19
+ from flextool.scenario_comparison.dispatch_mappings import (
20
+ combine_dispatch_mappings,
21
+ data_scenario_tags,
22
+ )
23
+ from flextool.scenario_comparison.dispatch_plots import create_dispatch_plots
24
+ from flextool.scenario_comparison.plan_union import (
25
+ derive_comparison_config,
26
+ has_comparison_view,
27
+ )
28
+ from flextool.plot_outputs.config import _is_single_config, flatten_new_format
29
+ from flextool.plot_outputs.orchestrator import plot_dict_of_dataframes
30
+ from flextool.plot_outputs.color_template import resolve_plot_settings_path
31
+
32
+
33
+ def _seed_dispatch_colors_into_plot_settings(
34
+ project_path: Path,
35
+ color_path: Path,
36
+ mappings: DispatchMappings,
37
+ dispatch_scenarios: list[str],
38
+ ) -> None:
39
+ """Auto-seed discovered dispatch entity / scenario colors additively.
40
+
41
+ Relocates ``config_builder``'s color-seeding intent from ``config.yaml``
42
+ to the project ``plot_settings.yaml``: discover the run's group / unit /
43
+ connection / scenario names, assign each a default palette color, and
44
+ splice the genuinely-new ones into the project settings file without
45
+ disturbing existing content.
46
+
47
+ When *color_path* is the bundled fallback (the project has no own
48
+ ``plot_settings.yaml`` yet), seed the project copy first via
49
+ :func:`flextool.gui.project_utils.seed_plot_settings` so we never write
50
+ the packaged default. Non-fatal: any failure is logged and the run
51
+ continues.
52
+ """
53
+ from flextool.plot_outputs.color_template import (
54
+ _clear_cache,
55
+ _default_path,
56
+ )
57
+ from flextool.scenario_comparison.plot_settings_seed import (
58
+ seed_colors_into_plot_settings,
59
+ )
60
+
61
+ try:
62
+ discovered = discover_dispatch_entities(mappings, dispatch_scenarios)
63
+ entity_colors: dict[str, dict[str, str]] = {}
64
+ for cls in ("nodeGroup", "flowGroup", "unit", "connection"):
65
+ names = discovered.get(cls) or []
66
+ if names:
67
+ entity_colors[cls] = assign_palette_colors(names)
68
+ scenario_colors = assign_palette_colors(
69
+ discovered.get("scenarios") or [], use_tab10=True
70
+ )
71
+ if not any(entity_colors.values()) and not scenario_colors:
72
+ return
73
+
74
+ # Resolve to the project's own file; if the resolver handed back the
75
+ # bundled default, seed the project copy first (never write the
76
+ # package file).
77
+ target = Path(color_path)
78
+ if target == Path(_default_path()):
79
+ from flextool.gui.project_utils import seed_plot_settings
80
+ target = seed_plot_settings(project_path)
81
+
82
+ changed = seed_colors_into_plot_settings(
83
+ target, entity_colors, scenario_colors
84
+ )
85
+ if changed:
86
+ # The settings file changed on disk — drop the mtime-keyed cache
87
+ # so downstream readers in this same process see the new colors.
88
+ _clear_cache()
89
+ print(f"Seeded dispatch colors into {target}")
90
+ except Exception as exc: # noqa: BLE001 - seeding must never abort a run
91
+ import logging as _logging
92
+ _logging.warning(
93
+ "Auto-seeding plot_settings.yaml failed (non-fatal): %s", exc
94
+ )
95
+
96
+
97
+ def derive_project_root(scenario_folders: dict[str, str] | None) -> Path | None:
98
+ """Derive the common project root from the scenarios' ``output_location``s.
99
+
100
+ Each scenario's folder (the per-scenario ``output_location`` read from the
101
+ DB, or the parquet base dir in ``--parquet-base-dir`` mode) is, under the
102
+ project-rooted output layout, the project folder itself. The comparison
103
+ run's project root is therefore the common ancestor of those folders.
104
+
105
+ "Sensible common project root" predicate
106
+ ----------------------------------------
107
+ A sensible project root must be one the scenarios genuinely *share*, never
108
+ a far-up directory like ``/`` or ``/home`` that merely happens to be a
109
+ common ancestor of folders in different projects. The only such guaranteed
110
+ value is a folder that **all** scenarios report identically: under FlexTool's
111
+ project-rooted output layout every scenario of one project reports the same
112
+ ``output_location`` (the project folder), and the ``--parquet-base-dir``
113
+ mode maps every scenario to the same base dir
114
+ (see :func:`build_scenario_folders_from_dir`). So:
115
+
116
+ * if every non-empty folder normalizes to the **same** absolute path, that
117
+ shared folder is the project root;
118
+ * otherwise the scenarios diverge (different projects, or a one-off
119
+ cross-project comparison) and there is **no** sensible common root —
120
+ taking ``commonpath`` here would pick a sibling-parent / ``/home`` / ``/``
121
+ ancestor that belongs to no single project, so we deliberately decline.
122
+
123
+ Returns the resolved project-root :class:`~pathlib.Path`, or ``None`` when
124
+ there is no sensible common root (empty/missing locations, or divergent
125
+ folders) — the caller then falls back to the CWD-relative default. Pure
126
+ path logic; no filesystem access, deterministic.
127
+ """
128
+ if not scenario_folders:
129
+ return None
130
+ folders = [
131
+ os.path.normpath(os.path.abspath(str(f)))
132
+ for f in scenario_folders.values()
133
+ if f
134
+ ]
135
+ if not folders:
136
+ return None
137
+
138
+ unique = set(folders)
139
+ if len(unique) == 1:
140
+ return Path(next(iter(unique)))
141
+
142
+ # Divergent folders: no folder is shared by all scenarios, so any common
143
+ # ancestor would be a far-up directory belonging to no single project.
144
+ return None
145
+
146
+
147
+ def _derive_comparison_settings(plots_flat: dict) -> dict:
148
+ """Map each leaf config dict through ``derive_comparison_config``.
149
+
150
+ Drops entries with no comparison view — those lacking ``scenario_rule``
151
+ *and* an explicit ``comparison_overrides.map_dimensions_for_plots`` (see
152
+ :func:`~flextool.scenario_comparison.plan_union.has_comparison_view`).
153
+ Produces the same flat-dict shape that ``plot_dict_of_dataframes`` and
154
+ ``compute_all_plot_plans`` expect.
155
+ """
156
+ out: dict = {}
157
+ for rk, cfg_dict in plots_flat.items():
158
+ if not isinstance(cfg_dict, dict):
159
+ continue
160
+ if _is_single_config(cfg_dict):
161
+ # Direct config (no sub_configs)
162
+ if not has_comparison_view(cfg_dict):
163
+ continue
164
+ try:
165
+ out[rk] = derive_comparison_config(cfg_dict)
166
+ except (ValueError, TypeError):
167
+ continue
168
+ continue
169
+ # Named sub-configs
170
+ new_sub: dict = {}
171
+ for sub, leaf in cfg_dict.items():
172
+ if not isinstance(leaf, dict):
173
+ continue
174
+ if not has_comparison_view(leaf):
175
+ continue
176
+ try:
177
+ new_sub[sub] = derive_comparison_config(leaf)
178
+ except (ValueError, TypeError):
179
+ continue
180
+ if new_sub:
181
+ out[rk] = new_sub
182
+ return out
183
+
184
+
185
+ def run(
186
+ db_url: str | None,
187
+ parquet_subdir: str,
188
+ plot_dir: str,
189
+ output_config_path: str,
190
+ active_configs: list[str],
191
+ plot_rows: list[int],
192
+ write_to_xlsx: bool,
193
+ write_dispatch_xlsx: bool,
194
+ write_to_ods: bool,
195
+ show_plots: bool,
196
+ dispatch_plots: bool,
197
+ plot_file_format: str = 'png',
198
+ scenario_folders: dict[str, str] | None = None,
199
+ excel_dir: str | None = None,
200
+ shared_legend: bool = True,
201
+ only_first_file: bool = False,
202
+ comparison_parquet_dir: str | None = None,
203
+ debug: bool = False,
204
+ plot_dir_is_default: bool = False,
205
+ ) -> None:
206
+ """Run the full scenario-comparison pipeline.
207
+
208
+ Takes already-resolved parameters (CLI > settings DB > defaults)
209
+ and orchestrates: load data → build config → generate plots → write Excel.
210
+
211
+ When *scenario_folders* is provided the database is not queried and
212
+ *db_url* may be ``None``.
213
+
214
+ *plot_dir_is_default* is ``True`` when the caller did not explicitly set
215
+ ``--plot-dir`` (it carries the bare CWD-relative default). In that case the
216
+ plot output dir is re-rooted at ``<project_root>/output_plot_comparisons``
217
+ when the participating scenarios share a sensible common project root (see
218
+ :func:`derive_project_root`). An explicit ``--plot-dir`` always wins.
219
+ """
220
+ with open(output_config_path, 'r', encoding='utf-8') as f:
221
+ settings = yaml.safe_load(f)
222
+
223
+ scenario_folders, results = get_scenario_results(
224
+ db_url=db_url, parquet_subdir=parquet_subdir,
225
+ scenario_folders=scenario_folders,
226
+ )
227
+ combined_dfs = results.to_dict()
228
+
229
+ # Derive the project root from the participating scenarios' output
230
+ # locations. When sensible (see ``derive_project_root``) it roots both the
231
+ # ``plot_settings.yaml`` lookup and — when ``--plot-dir`` was left at its
232
+ # default — the comparison plot output dir, giving Toolbox/GUI parity with
233
+ # the per-project ``plot_settings.yaml``. ``None`` means no sensible common
234
+ # root → fall back to the CWD-relative behaviour unchanged.
235
+ project_root = derive_project_root(scenario_folders)
236
+
237
+ # Re-root the default plot dir at ``<project_root>/output_plot_comparisons``
238
+ # only when the user did not pass an explicit ``--plot-dir`` and a sensible
239
+ # project root exists. An explicit ``--plot-dir`` always wins.
240
+ if plot_dir_is_default and project_root is not None:
241
+ plot_dir = str(project_root / "output_plot_comparisons")
242
+
243
+ os.makedirs(plot_dir, exist_ok=True)
244
+
245
+ # The plot color template comes from the project's ``plot_settings.yaml``
246
+ # when present, else the bundled default. Prefer the derived project root;
247
+ # only when there is no sensible common root do we fall back to the
248
+ # ``plot_dir`` parent (CWD-relative legacy behaviour).
249
+ settings_root = project_root if project_root is not None else Path(plot_dir).parent
250
+ color_path = resolve_plot_settings_path(settings_root)
251
+
252
+ scenarios = list(scenario_folders.keys())
253
+
254
+ # Load and combine dispatch mappings across all scenarios
255
+ if scenario_folders:
256
+ mappings = combine_dispatch_mappings(scenario_folders, parquet_subdir)
257
+ combined_mapping_dfs = {
258
+ k: v for k, v in vars(mappings).items() if v is not None
259
+ }
260
+ else:
261
+ mappings = DispatchMappings()
262
+ combined_mapping_dfs = {}
263
+
264
+ # Folder names (``scenarios``) drive paths, _metadata.json and the
265
+ # viewer-set match, but dispatch slicing keys off the scenario tag
266
+ # embedded in the data — which differs when a folder carries the GUI
267
+ # run-index suffix (folder S2_Dry_1 → tag S2_Dry). Resolve the tags
268
+ # from the data and use them for every dispatch slice.
269
+ dispatch_scenarios = data_scenario_tags(mappings) or scenarios
270
+
271
+ # Derive group_node_df for summary plots (needs 'scenario' as column)
272
+ group_node_combined = combined_mapping_dfs.get('group_node')
273
+ if group_node_combined is not None and not group_node_combined.empty:
274
+ group_node_combined.reset_index()
275
+
276
+ # Additively seed discovered entity / scenario colors into the project
277
+ # ``plot_settings.yaml`` (the durable, comment-preserving colors file the
278
+ # renderers read). This is the sole color/order/scenario source now; the
279
+ # legacy ``config.yaml`` system has been removed.
280
+ if dispatch_plots:
281
+ _seed_dispatch_colors_into_plot_settings(
282
+ settings_root, color_path, mappings, dispatch_scenarios
283
+ )
284
+ # Seeding may have created the project ``plot_settings.yaml`` (when
285
+ # only the bundled default existed); re-resolve so the renderers and
286
+ # the viewer plan use the freshly-seeded project file.
287
+ color_path = resolve_plot_settings_path(settings_root)
288
+
289
+ # Flatten new-format entries to flat result_key mapping for downstream use.
290
+ # Then derive comparison-mode configs from each leaf (single rules +
291
+ # scenario_rule), since the merged ``default_plots.yaml`` carries
292
+ # single-mode rules. Entries without ``scenario_rule`` have no
293
+ # comparison rendering and are dropped here.
294
+ settings['plots'] = _derive_comparison_settings(
295
+ flatten_new_format(settings.get('plots', {}))
296
+ )
297
+
298
+ # If shared_legend is disabled, replace 'shared' legend with 'right' in all plot configs
299
+ if not shared_legend:
300
+ for result_name, config_dict in settings['plots'].items():
301
+ if not isinstance(config_dict, dict):
302
+ continue
303
+ if _is_single_config(config_dict):
304
+ # Single config: the dict itself is the plot settings
305
+ if config_dict.get('legend') == 'shared':
306
+ config_dict['legend'] = 'right'
307
+ else:
308
+ # Named configs: iterate sub-dicts
309
+ for config_name, plot_cfg in config_dict.items():
310
+ if isinstance(plot_cfg, dict) and plot_cfg.get('legend') == 'shared':
311
+ plot_cfg['legend'] = 'right'
312
+
313
+ # Load timeline breaks from all scenario parquet dirs.
314
+ # For --parquet-base-dir mode (subdir=''): parquets at base_dir/name/
315
+ # For DB mode (subdir='output_parquet'): parquets at folder/subdir/name/
316
+ from flextool.plot_outputs.format_helpers import load_timeline_breaks
317
+ break_dirs = []
318
+ for name, folder in scenario_folders.items():
319
+ if parquet_subdir:
320
+ break_dirs.append(os.path.join(folder, parquet_subdir, name))
321
+ else:
322
+ break_dirs.append(os.path.join(folder, name))
323
+ break_times = load_timeline_breaks(*break_dirs)
324
+
325
+ # Write combined comparison parquets if output directory specified
326
+ if comparison_parquet_dir:
327
+ os.makedirs(comparison_parquet_dir, exist_ok=True)
328
+ for name, df in combined_dfs.items():
329
+ if not df.empty:
330
+ write_lean_parquet(df, os.path.join(comparison_parquet_dir, f"{name}.parquet"))
331
+ # Also write a metadata file with the scenario list
332
+ import json
333
+ meta = {"scenarios": scenarios}
334
+ with open(os.path.join(comparison_parquet_dir, "_metadata.json"), "w") as f:
335
+ json.dump(meta, f)
336
+ if break_times:
337
+ # Save break times so the viewer can load them
338
+ bt_df = pd.DataFrame({"break_time": list(break_times)})
339
+ write_lean_parquet(bt_df, os.path.join(comparison_parquet_dir, "timeline_breaks.parquet"), index=False)
340
+ print(f"Wrote comparison parquets to: {comparison_parquet_dir}")
341
+
342
+ # Compute and save dispatch metadata (cross-scenario ylims) for the viewer
343
+ if combined_mapping_dfs and comparison_parquet_dir:
344
+ try:
345
+ from flextool.scenario_comparison.dispatch_plots import compute_dispatch_metadata
346
+ # Use same timeline as dispatch plots
347
+ if plot_rows and len(plot_rows) >= 2:
348
+ meta_timeline = (int(plot_rows[0]), int(plot_rows[1]) + 1)
349
+ else:
350
+ meta_timeline = (0, 168)
351
+ dispatch_meta = compute_dispatch_metadata(
352
+ results, mappings, dispatch_scenarios, meta_timeline,
353
+ )
354
+ import json as _json
355
+ meta_path = os.path.join(comparison_parquet_dir, "_dispatch_metadata.json")
356
+ with open(meta_path, "w") as f:
357
+ _json.dump(dispatch_meta, f, indent=2)
358
+ print(f"Wrote dispatch metadata to: {meta_path}")
359
+ except Exception as exc:
360
+ import logging as _logging
361
+ _logging.warning("Dispatch metadata computation failed (non-fatal): %s", exc)
362
+
363
+ # Compute plot plans for the viewer (always in comparison parquet dir)
364
+ if comparison_parquet_dir:
365
+ try:
366
+ from flextool.plot_outputs.orchestrator import compute_all_plot_plans
367
+ compute_all_plot_plans(
368
+ combined_dfs, settings.get('plots', {}), comparison_parquet_dir,
369
+ active_settings=active_configs, plot_rows=plot_rows,
370
+ break_times=break_times,
371
+ strip_scenario_level=False,
372
+ color_path=color_path,
373
+ )
374
+ print("Computed plot plans for viewer")
375
+ except Exception as exc:
376
+ import logging as _logging
377
+ _logging.warning("Plot plan computation failed (non-fatal): %s", exc)
378
+
379
+ # Generate comparison plots from the merged settings derived above.
380
+ plot_dict_of_dataframes(
381
+ combined_dfs, plot_dir, settings['plots'],
382
+ active_settings=active_configs, plot_rows=plot_rows,
383
+ delete_existing_plots=True, plot_file_format=plot_file_format,
384
+ only_first_file=only_first_file,
385
+ break_times=break_times,
386
+ color_path=color_path,
387
+ )
388
+ print(f'\nPlotted comparison of {len(scenario_folders)} scenarios to folder: {plot_dir}')
389
+
390
+ # Generate dispatch plots
391
+ if dispatch_plots:
392
+ if combined_mapping_dfs:
393
+ print("\nGenerating dispatch plots...")
394
+ create_dispatch_plots(
395
+ results, mappings, plot_dir,
396
+ scenarios=dispatch_scenarios,
397
+ show_plot=show_plots,
398
+ write_xlsx=write_dispatch_xlsx,
399
+ break_times=break_times,
400
+ plot_rows=plot_rows,
401
+ debug=debug,
402
+ settings_path=color_path,
403
+ )
404
+ else:
405
+ print("Warning: Cannot generate dispatch plots - missing dispatch mappings")
406
+
407
+ # Write to Excel (combined results)
408
+ if write_to_xlsx:
409
+ # Load rename/export config: use the comparison YAML's rename section
410
+ # if present, otherwise fall back to default_plots.yaml.
411
+ rename_raw = settings.get('rename')
412
+ if not rename_raw:
413
+ try:
414
+ default_plots_path = os.path.join(
415
+ os.path.dirname(output_config_path), 'default_plots.yaml',
416
+ )
417
+ with open(default_plots_path, 'r', encoding='utf-8') as f_dp:
418
+ rename_raw = yaml.safe_load(f_dp).get('rename', {})
419
+ except Exception:
420
+ rename_raw = {}
421
+
422
+ def _parse_rename(entry):
423
+ if isinstance(entry, list) and len(entry) >= 2:
424
+ return str(entry[0]), bool(entry[1])
425
+ return str(entry), True
426
+
427
+ filename = 'compare_' + str(len(scenario_folders)) + '_scens.xlsx'
428
+ target_dir = excel_dir if excel_dir is not None else plot_dir
429
+ excel_path = os.path.join(target_dir, filename)
430
+ # Build list of (sheet_name, df) sorted alphabetically
431
+ sheets: list[tuple[str, pd.DataFrame]] = []
432
+ used_names: set[str] = set()
433
+ for name, df in combined_dfs.items():
434
+ display_name, export = _parse_rename(rename_raw.get(name, name))
435
+ if not export:
436
+ continue
437
+ if (not df.empty) & (len(df) > 0):
438
+ sheet_name = display_name[:31]
439
+ if sheet_name in used_names:
440
+ suffix = 1
441
+ while f"{sheet_name[:28]}_{suffix}" in used_names:
442
+ suffix += 1
443
+ sheet_name = f"{sheet_name[:28]}_{suffix}"
444
+ used_names.add(sheet_name)
445
+ sheets.append((sheet_name, df))
446
+ sheets.sort(key=lambda x: x[0].lower())
447
+
448
+ with pd.ExcelWriter(excel_path, engine='xlsxwriter') as writer:
449
+ for sheet_name, df in sheets:
450
+ df.to_excel(writer, sheet_name=sheet_name)
451
+ print(f'\nWrote comparison of {len(scenario_folders)} scenarios to xlsx file: {excel_path}')
452
+
453
+ print('\nDone!')