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,222 @@
1
+ """Typed dataclasses replacing the raw dicts ``combined_dfs`` and ``combined_mapping_dfs``.
2
+
3
+ TimeSeriesResults — combined time-series result DataFrames (scenario in column MultiIndex).
4
+ DispatchMappings — dispatch mapping DataFrames combined across scenarios (scenario in row index).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, fields
10
+
11
+ import pandas as pd
12
+
13
+
14
+ # Parquet filenames that are dispatch mapping / structural files loaded separately
15
+ # by dispatch_mappings.py. These are NOT time-series results and should be excluded
16
+ # from the TimeSeriesResults.from_dict() unknown-key warning.
17
+ _DISPATCH_MAPPING_FILENAMES: frozenset[str] = frozenset({
18
+ # Loaded by dispatch_mappings.load_dispatch_mappings():
19
+ 'group_node',
20
+ 'group_process_node',
21
+ 'nodeGroupIndicators', # → dispatch_groups (dispatch scoping for indicator groups)
22
+ 'flowGroupIndicators',
23
+ 'nodeGroupDispatch__processGroup_Unit_to_group',
24
+ 'nodeGroupDispatch__processGroup_Group_to_unit',
25
+ 'nodeGroupDispatch__processGroup_Connection',
26
+ 'nodeGroupDispatch__processGroup__process__unit__to_node',
27
+ 'nodeGroupDispatch__processGroup__process__node__to_unit',
28
+ 'nodeGroupDispatch__processGroup__process__connection__to_node',
29
+ 'nodeGroupDispatch__processGroup__process__node__to_connection',
30
+ 'nodeGroupDispatch__process__unit__to_node_Not_in_aggregate',
31
+ 'nodeGroupDispatch__process__node__to_unit_Not_in_aggregate',
32
+ 'nodeGroupDispatch__process__connection__to_node_Not_in_aggregate',
33
+ 'nodeGroupDispatch__process__node__to_connection_Not_in_aggregate',
34
+ 'nodeGroupDispatch__connection_Not_in_aggregate',
35
+ 'nodeGroupDispatch__process_fully_inside',
36
+ # Other structural/metadata files (not time-series):
37
+ 'group_process',
38
+ 'nodeGroupDispatch',
39
+ # DC power flow structural sets:
40
+ 'node_dc_power_flow',
41
+ 'connection_dc_power_flow',
42
+ })
43
+
44
+
45
+ @dataclass
46
+ class TimeSeriesResults:
47
+ """Combined time-series result DataFrames (scenario in column MultiIndex).
48
+
49
+ To add a new FlexTool output variable:
50
+ 1. Add a field below with type ``pd.DataFrame | None = None``
51
+ 2. The field name must exactly match the parquet filename (without .parquet)
52
+ 3. from_dict() will populate it automatically; a warning flags unknown files
53
+ """
54
+
55
+ # --- Unit outputs ---
56
+ unit_outputNode_dt_ee: pd.DataFrame | None = None
57
+ unit_outputNode_d_ee: pd.DataFrame | None = None
58
+ unit_inputNode_dt_ee: pd.DataFrame | None = None
59
+ unit_inputNode_d_ee: pd.DataFrame | None = None
60
+ unit_outputs_cf_d_ee: pd.DataFrame | None = None
61
+ unit_inputs_cf_d_ee: pd.DataFrame | None = None
62
+ unit_curtailment_outputNode_dt_ee: pd.DataFrame | None = None
63
+ unit_curtailment_outputNode_d_ee: pd.DataFrame | None = None
64
+ unit_curtailment_share_outputNode_dt_ee: pd.DataFrame | None = None
65
+ unit_curtailment_share_outputNode_d_ee: pd.DataFrame | None = None
66
+ unit_VRE_potential_outputNode_dt_ee: pd.DataFrame | None = None
67
+ unit_VRE_potential_outputNode_d_ee: pd.DataFrame | None = None
68
+ unit_ramp_outputs_dt_ee: pd.DataFrame | None = None
69
+ unit_ramp_inputs_dt_ee: pd.DataFrame | None = None
70
+ unit_online_dt_e: pd.DataFrame | None = None
71
+ unit_online_average_d_e: pd.DataFrame | None = None
72
+ unit_startup_d_e: pd.DataFrame | None = None
73
+ unit_capacity_ed_p: pd.DataFrame | None = None
74
+
75
+ # --- Connection outputs ---
76
+ connection_leftward_dt_eee: pd.DataFrame | None = None
77
+ connection_leftward_d_eee: pd.DataFrame | None = None
78
+ connection_rightward_dt_eee: pd.DataFrame | None = None
79
+ connection_rightward_d_eee: pd.DataFrame | None = None
80
+ connection_losses_dt_eee: pd.DataFrame | None = None
81
+ connection_losses_d_eee: pd.DataFrame | None = None
82
+ connection_dt_eee: pd.DataFrame | None = None
83
+ connection_d_eee: pd.DataFrame | None = None
84
+ connection_cf_d_e: pd.DataFrame | None = None
85
+ connection_capacity_ed_p: pd.DataFrame | None = None
86
+
87
+ # --- Node outputs ---
88
+ node_d_ep: pd.DataFrame | None = None
89
+ node_dt_ep: pd.DataFrame | None = None
90
+ node_inflow__dt: pd.DataFrame | None = None
91
+ node_prices_dt_e: pd.DataFrame | None = None
92
+ node_state_dt_e: pd.DataFrame | None = None
93
+ node_slack_up_dt_e: pd.DataFrame | None = None
94
+ node_slack_up_d_e: pd.DataFrame | None = None
95
+ node_slack_down_dt_e: pd.DataFrame | None = None
96
+ node_slack_down_d_e: pd.DataFrame | None = None
97
+ node_capacity_ed_p: pd.DataFrame | None = None
98
+
99
+ # --- NodeGroup outputs ---
100
+ nodeGroup_flows_d_gpe: pd.DataFrame | None = None
101
+ nodeGroup_flows_dt_gpe: pd.DataFrame | None = None
102
+ nodeGroup_flows_d_g: pd.DataFrame | None = None
103
+ nodeGroup_flows_dt_g: pd.DataFrame | None = None
104
+ nodeGroup_gd_p: pd.DataFrame | None = None
105
+ nodeGroup_gdt_p: pd.DataFrame | None = None
106
+ nodeGroup_VRE_share_d_g: pd.DataFrame | None = None
107
+ nodeGroup_VRE_share_dt_g: pd.DataFrame | None = None
108
+ nodeGroup_inertia_dt_g: pd.DataFrame | None = None
109
+ nodeGroup_inertia_largest_flow_dt_g: pd.DataFrame | None = None
110
+ nodeGroup_unit_node_inertia_dt_gee: pd.DataFrame | None = None
111
+ nodeGroup_slack_capacity_margin_d_g: pd.DataFrame | None = None
112
+ nodeGroup_slack_inertia_d_g: pd.DataFrame | None = None
113
+ nodeGroup_slack_inertia_dt_g: pd.DataFrame | None = None
114
+ nodeGroup_slack_nonsync_d_g: pd.DataFrame | None = None
115
+ nodeGroup_slack_nonsync_dt_g: pd.DataFrame | None = None
116
+ nodeGroup_slack_reserve_d_eeg: pd.DataFrame | None = None
117
+ nodeGroup_slack_reserve_dt_eeg: pd.DataFrame | None = None
118
+
119
+ # --- Costs ---
120
+ costs_dt_p: pd.DataFrame | None = None
121
+ costs_discounted_d_p: pd.DataFrame | None = None
122
+ costs_discounted_p_: pd.DataFrame | None = None
123
+ annualized_costs_d_p: pd.DataFrame | None = None
124
+
125
+ # --- Per-entity cost break-down (unit/connection/node, period level) ---
126
+ cost_unit_annualized_d_ec: pd.DataFrame | None = None
127
+ cost_connection_annualized_d_ec: pd.DataFrame | None = None
128
+ cost_node_annualized_d_ec: pd.DataFrame | None = None
129
+ cost_unit_discounted_d_ec: pd.DataFrame | None = None
130
+ cost_connection_discounted_d_ec: pd.DataFrame | None = None
131
+ cost_node_discounted_d_ec: pd.DataFrame | None = None
132
+
133
+ # --- CO2 and process outputs ---
134
+ CO2__: pd.DataFrame | None = None
135
+ CO2_d_g: pd.DataFrame | None = None
136
+ co2_price_period_d_g: pd.DataFrame | None = None
137
+ co2_price_total_d_g: pd.DataFrame | None = None
138
+ process_co2_d_eee: pd.DataFrame | None = None
139
+ process_reserve_average_d_eppe: pd.DataFrame | None = None
140
+ process_reserve_upDown_node_dt_eppe: pd.DataFrame | None = None
141
+
142
+ # --- DC power flow ---
143
+ dc_angle_dt_e: pd.DataFrame | None = None
144
+ dc_angle_diff_dt_e: pd.DataFrame | None = None
145
+
146
+ # --- Other ---
147
+ reserve_prices_dt_ppg: pd.DataFrame | None = None
148
+ years_represented__d: pd.DataFrame | None = None
149
+ dual_invest_effective_node_d_e: pd.DataFrame | None = None
150
+ dual_invest_effective_unit_d_e: pd.DataFrame | None = None
151
+ dual_invest_effective_connection_d_e: pd.DataFrame | None = None
152
+
153
+ # --- Flow groups (v58) ---
154
+ flowGroup_gd_p: pd.DataFrame | None = None
155
+ flowGroup_gd_t: pd.DataFrame | None = None
156
+
157
+ def to_dict(self) -> dict[str, pd.DataFrame]:
158
+ """All non-None DataFrames as a dict, for plot_dict_of_dataframes()."""
159
+ return {
160
+ f.name: getattr(self, f.name)
161
+ for f in fields(self)
162
+ if getattr(self, f.name) is not None
163
+ }
164
+
165
+ @classmethod
166
+ def from_dict(cls, d: dict[str, pd.DataFrame]) -> TimeSeriesResults:
167
+ """Build from the raw dict produced by combine_parquet_files().
168
+
169
+ Warns about unknown keys (signals a field needs to be added).
170
+ Dispatch mapping files (loaded separately) are silently excluded.
171
+ """
172
+ known = {f.name for f in fields(cls)}
173
+ unknown = set(d.keys()) - known - _DISPATCH_MAPPING_FILENAMES
174
+ if unknown:
175
+ print(
176
+ f"Warning: Result variables not registered in TimeSeriesResults "
177
+ f"(add fields to data_models.py): {sorted(unknown)}"
178
+ )
179
+ return cls(**{k: v for k, v in d.items() if k in known})
180
+
181
+
182
+ @dataclass
183
+ class DispatchMappings:
184
+ """Dispatch mapping DataFrames combined across scenarios (scenario in row index).
185
+
186
+ Use get_for_scenario(field_name, scenario) to extract a per-scenario slice.
187
+ """
188
+
189
+ dispatch_groups: pd.DataFrame | None = None
190
+ group_node: pd.DataFrame | None = None
191
+ group_process_node: pd.DataFrame | None = None
192
+ processGroup_Unit_to_group: pd.DataFrame | None = None
193
+ processGroup_Group_to_unit: pd.DataFrame | None = None
194
+ processGroup_Connection: pd.DataFrame | None = None
195
+ processGroup_unit_to_node_members: pd.DataFrame | None = None
196
+ processGroup_node_to_unit_members: pd.DataFrame | None = None
197
+ processGroup_connection_to_node_members: pd.DataFrame | None = None
198
+ processGroup_node_to_connection_members: pd.DataFrame | None = None
199
+ not_in_aggregate_unit_to_node: pd.DataFrame | None = None
200
+ not_in_aggregate_node_to_unit: pd.DataFrame | None = None
201
+ not_in_aggregate_connection_to_node: pd.DataFrame | None = None
202
+ not_in_aggregate_node_to_connection: pd.DataFrame | None = None
203
+ not_in_aggregate_connection: pd.DataFrame | None = None
204
+ process_fully_inside: pd.DataFrame | None = None
205
+
206
+ def get_for_scenario(self, field_name: str, scenario: str) -> pd.DataFrame | None:
207
+ """Extract per-scenario slice from a mapping field.
208
+
209
+ Handles xs() returning Series when only one row matches.
210
+ Returns None if field is absent or scenario not found.
211
+ """
212
+ df = getattr(self, field_name, None)
213
+ if df is None or df.empty:
214
+ return None
215
+ if df.index.name == 'scenario':
216
+ if scenario not in df.index:
217
+ return None
218
+ result = df.xs(scenario)
219
+ if isinstance(result, pd.Series):
220
+ return result.to_frame().T
221
+ return result
222
+ return df
@@ -0,0 +1,399 @@
1
+ """Load parquet files from scenario folders and combine into TimeSeriesResults.
2
+
3
+ Functions:
4
+ - read_scenario_folders : read scenario→folder mapping from Spine DB
5
+ - collect_parquet_files : gather parquet paths grouped by filename
6
+ - combine_parquet_files : concat per-scenario parquets into combined DataFrames
7
+ - get_scenario_results : top-level convenience (returns TimeSeriesResults)
8
+ - combine_scenario_parquets : combine per-scenario parquets from disk (for GUI)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ import pandas as pd
17
+ from spinedb_api import DatabaseMapping
18
+
19
+ from flextool.lean_parquet import read_lean_parquet, write_lean_parquet
20
+ from flextool.scenario_comparison.data_models import TimeSeriesResults
21
+
22
+
23
+ def read_scenario_folders(db_url: str) -> dict[str, str]:
24
+ """Read the scenario database to get all folder paths.
25
+
26
+ Parameters
27
+ ----------
28
+ db_url : str
29
+ Database URL containing scenario information
30
+
31
+ Returns
32
+ -------
33
+ dict
34
+ Dictionary mapping scenario names to folder paths
35
+ """
36
+ scenario_folders: dict[str, str] = {}
37
+
38
+ with DatabaseMapping(db_url) as db_map:
39
+ # At the results stage, scenarios have been flattened to alternatives.
40
+ # Get alternative names from URL filter config (set by Spine Toolbox)
41
+ # or fall back to reading all alternatives from the database.
42
+ filter_configs = db_map.get_filter_configs()
43
+ alternative_names: list[str] = []
44
+ for cfg in filter_configs:
45
+ if cfg.get('type') == 'alternative_filter':
46
+ alternative_names = cfg['alternatives']
47
+ break
48
+ if not alternative_names:
49
+ alternative_names = [
50
+ a["name"] for a in db_map.get_alternative_items()
51
+ ]
52
+
53
+ for alt_name in alternative_names:
54
+ param_values = db_map.get_parameter_value_items(
55
+ entity_class_name="scenario",
56
+ entity_name=alt_name,
57
+ parameter_definition_name="output_location",
58
+ )
59
+
60
+ if param_values:
61
+ folder_path = param_values[0]["parsed_value"]
62
+ scenario_folders[alt_name] = folder_path
63
+
64
+ return scenario_folders
65
+
66
+
67
+ def collect_parquet_files(
68
+ scenario_folders: dict[str, str],
69
+ output_subdir: str = "output_parquet",
70
+ ) -> dict[str, list[tuple[str, Path]]]:
71
+ """Collect all parquet files from all scenario folders.
72
+
73
+ Parameters
74
+ ----------
75
+ scenario_folders : dict
76
+ Dictionary mapping scenario names to folder paths
77
+ output_subdir : str
78
+ Subdirectory within each folder containing parquet files
79
+
80
+ Returns
81
+ -------
82
+ dict
83
+ Dictionary mapping filename to list of (scenario_name, file_path) tuples
84
+ """
85
+ files_by_name: dict[str, list[tuple[str, Path]]] = {}
86
+
87
+ for scenario_name, folder_path in scenario_folders.items():
88
+ parquet_dir = Path(folder_path) / output_subdir / scenario_name
89
+
90
+ if not parquet_dir.exists():
91
+ print(f"Warning: {parquet_dir} does not exist for scenario {scenario_name}")
92
+ continue
93
+
94
+ for parquet_file in sorted(parquet_dir.glob("*.parquet")):
95
+ filename = parquet_file.name
96
+
97
+ # Skip metadata files (not result variables)
98
+ if filename == 'timeline_breaks.parquet':
99
+ continue
100
+
101
+ if filename not in files_by_name:
102
+ files_by_name[filename] = []
103
+
104
+ files_by_name[filename].append((scenario_name, parquet_file))
105
+
106
+ return files_by_name
107
+
108
+
109
+ def combine_parquet_files(
110
+ files_by_name: dict[str, list[tuple[str, Path]]],
111
+ num_scenarios: int = 0,
112
+ ) -> dict[str, pd.DataFrame]:
113
+ """Combine parquet files across scenarios into dataframes.
114
+
115
+ The parquet files are expected to have scenario information in a multi-index
116
+ column level (highest level), so they are appended along the column axis.
117
+
118
+ Parameters
119
+ ----------
120
+ files_by_name : dict
121
+ Dictionary mapping filename to list of (scenario_name, file_path) tuples
122
+ num_scenarios : int
123
+ Total number of scenarios being combined (for warning about missing files)
124
+
125
+ Returns
126
+ -------
127
+ dict
128
+ Dictionary mapping filename (without extension) to combined dataframe
129
+ """
130
+ combined_dfs: dict[str, pd.DataFrame] = {}
131
+
132
+ for filename, scenario_files in files_by_name.items():
133
+ variable_name = filename.replace('.parquet', '')
134
+
135
+ dfs_to_append = []
136
+
137
+ for scenario_name, file_path in scenario_files:
138
+ try:
139
+ df = read_lean_parquet(file_path)
140
+ # Deduplicate index (can occur with overlapping solve windows)
141
+ if df.index.duplicated().any():
142
+ df = df[~df.index.duplicated(keep='last')]
143
+ # Re-tag the scenario column level to the folder identity
144
+ # (the loop key) so two folders holding the same model
145
+ # scenario stay distinct and all downstream slicing keys off
146
+ # the folder name. No-op (and byte-identical) when the
147
+ # baked-in tag already equals the folder, e.g. the DB/CLI
148
+ # path. See specs/dispatch_scenario_identity_retag.md.
149
+ if (
150
+ isinstance(df.columns, pd.MultiIndex)
151
+ and 'scenario' in df.columns.names
152
+ ):
153
+ src = list(df.columns.get_level_values('scenario').unique())
154
+ if src != [scenario_name]:
155
+ df = df.rename(
156
+ columns={s: scenario_name for s in src},
157
+ level='scenario',
158
+ )
159
+ elif (
160
+ not isinstance(df.columns, pd.MultiIndex)
161
+ and df.columns.name == 'scenario'
162
+ ):
163
+ # Entity-less variables (e.g. period-indexed costs ``_p_``)
164
+ # carry a single-level column index named 'scenario'. The
165
+ # MultiIndex branch above skips these, so two folders that
166
+ # share a baked model tag (``trade_only_shipping_4`` and
167
+ # ``_2`` both baked as ``trade_only_shipping``) would emit
168
+ # duplicate columns after the cross-scenario concat and
169
+ # crash the downstream ``stack``. Re-tag here too.
170
+ src = list(df.columns.unique())
171
+ if src != [scenario_name]:
172
+ df = df.rename(columns={s: scenario_name for s in src})
173
+ dfs_to_append.append(df)
174
+
175
+ except Exception as e:
176
+ print(f"Error reading {file_path} for scenario {scenario_name}: {e}")
177
+ continue
178
+
179
+ if dfs_to_append:
180
+ combined_df = pd.concat(dfs_to_append, axis=1)
181
+ combined_dfs[variable_name] = combined_df
182
+ if num_scenarios > 0 and len(dfs_to_append) < num_scenarios:
183
+ print(f"Found and combined only {len(dfs_to_append)} out of {num_scenarios} "
184
+ f"possible files for variable '{variable_name}' (shape: {combined_df.shape})")
185
+ else:
186
+ print(f"Warning: No valid data found for {filename}")
187
+
188
+ return combined_dfs
189
+
190
+
191
+ def build_scenario_folders_from_dir(
192
+ base_dir: Path,
193
+ scenario_names: list[str],
194
+ ) -> dict[str, str]:
195
+ """Build scenario-to-folder mapping from a directory of per-scenario parquet subdirectories.
196
+
197
+ The returned mapping has the same format as :func:`read_scenario_folders`.
198
+ Each scenario maps to ``str(base_dir)`` so that the existing
199
+ ``collect_parquet_files`` / ``combine_dispatch_mappings`` helpers resolve
200
+ the final parquet directory as ``base_dir / scenario_name`` (when called
201
+ with ``output_subdir=""``).
202
+
203
+ Parameters
204
+ ----------
205
+ base_dir : Path
206
+ Root directory that contains one subdirectory per scenario, each
207
+ holding ``*.parquet`` files directly.
208
+ scenario_names : list[str]
209
+ Scenario (alternative) names whose subdirectories should be included.
210
+
211
+ Returns
212
+ -------
213
+ dict[str, str]
214
+ ``{"scenario_name": str(base_dir), ...}`` for every requested scenario
215
+ whose subdirectory exists under *base_dir*.
216
+ """
217
+ base_dir = Path(base_dir)
218
+ scenario_folders: dict[str, str] = {}
219
+ for name in scenario_names:
220
+ scenario_dir = base_dir / name
221
+ if not scenario_dir.is_dir():
222
+ print(f"Warning: expected scenario directory {scenario_dir} does not exist – skipping")
223
+ continue
224
+ scenario_folders[name] = str(base_dir)
225
+ return scenario_folders
226
+
227
+
228
+ def get_scenario_results(
229
+ db_url: str | None = None,
230
+ parquet_subdir: str = 'output_parquet',
231
+ scenario_folders: dict[str, str] | None = None,
232
+ ) -> tuple[dict[str, str], TimeSeriesResults]:
233
+ """Load and combine all scenario parquet files into TimeSeriesResults.
234
+
235
+ Parameters
236
+ ----------
237
+ db_url : str, optional
238
+ Database URL containing scenario information. Ignored when
239
+ *scenario_folders* is supplied.
240
+ parquet_subdir : str
241
+ Subdirectory within each scenario folder containing parquet files
242
+ scenario_folders : dict[str, str], optional
243
+ Pre-built scenario-name → folder-path mapping (e.g. from
244
+ :func:`build_scenario_folders_from_dir`). When provided the
245
+ database is not queried.
246
+
247
+ Returns
248
+ -------
249
+ tuple[dict[str, str], TimeSeriesResults]
250
+ (scenario_folders, results) — folder mapping and combined time-series data
251
+ """
252
+ if scenario_folders is None:
253
+ if db_url is None:
254
+ raise ValueError("Either db_url or scenario_folders must be provided")
255
+ print(f"Reading scenario information from {db_url}...")
256
+ scenario_folders = read_scenario_folders(db_url)
257
+ print(f"Found {len(scenario_folders)} scenarios: {list(scenario_folders.keys())}. Processing...", flush=True)
258
+
259
+ files_by_name = collect_parquet_files(scenario_folders, parquet_subdir)
260
+ print(f"Found {len(files_by_name)} unique result variables from parquet subdirectories")
261
+
262
+ num_scenarios = len(scenario_folders)
263
+ combined_dfs = combine_parquet_files(files_by_name, num_scenarios=num_scenarios)
264
+
265
+ return scenario_folders, TimeSeriesResults.from_dict(combined_dfs)
266
+
267
+
268
+ def combine_scenario_parquets(
269
+ project_path: Path,
270
+ scenario_names: list[str],
271
+ output_dir: Path | None = None,
272
+ ) -> Path:
273
+ """Combine per-scenario parquet files into comparison parquets.
274
+
275
+ Reads parquets from ``project_path/output_parquet/<scenario>/`` for each
276
+ scenario, concatenates them with scenario as the top column MultiIndex
277
+ level, and writes the combined files to *output_dir* (defaults to
278
+ ``project_path/output_parquet_comparison/``).
279
+
280
+ Also writes:
281
+ - ``_metadata.json`` with the scenario list
282
+ - ``timeline_breaks.parquet`` (merged from all scenarios)
283
+
284
+ Parameters
285
+ ----------
286
+ project_path : Path
287
+ Project root directory.
288
+ scenario_names : list[str]
289
+ Scenario names to combine.
290
+ output_dir : Path, optional
291
+ Where to write combined parquets. Defaults to
292
+ ``project_path / "output_parquet_comparison"``.
293
+
294
+ Returns
295
+ -------
296
+ Path
297
+ The output directory.
298
+ """
299
+ project_path = Path(project_path)
300
+ parquet_base = project_path / "output_parquet"
301
+
302
+ if output_dir is None:
303
+ output_dir = project_path / "output_parquet_comparison"
304
+ output_dir = Path(output_dir)
305
+
306
+ # 1. Build scenario folder mapping
307
+ scenario_folders = build_scenario_folders_from_dir(parquet_base, scenario_names)
308
+ if not scenario_folders:
309
+ raise FileNotFoundError(
310
+ f"No scenario directories found under {parquet_base} "
311
+ f"for scenarios: {scenario_names}"
312
+ )
313
+
314
+ # 2. Collect all parquet files grouped by filename
315
+ files_by_name = collect_parquet_files(scenario_folders, output_subdir="")
316
+
317
+ # 3. Combine parquet files across scenarios
318
+ combined_dfs = combine_parquet_files(
319
+ files_by_name, num_scenarios=len(scenario_names)
320
+ )
321
+
322
+ # 4. Write combined DataFrames to output_dir
323
+ # Skip mapping DataFrames (flat columns) — they have duplicate column
324
+ # names after axis=1 concat and are handled separately via dispatch
325
+ # mappings. Only write time-series DataFrames (MultiIndex columns).
326
+ output_dir.mkdir(parents=True, exist_ok=True)
327
+ for name, df in combined_dfs.items():
328
+ if df.empty:
329
+ continue
330
+ if not isinstance(df.columns, pd.MultiIndex):
331
+ continue
332
+ write_lean_parquet(df, output_dir / f"{name}.parquet")
333
+
334
+ # 5. Write metadata
335
+ meta = {"scenarios": list(scenario_folders.keys())}
336
+ with open(output_dir / "_metadata.json", "w") as f:
337
+ json.dump(meta, f)
338
+
339
+ # 6. Merge timeline breaks from all scenarios
340
+ break_times: set[str] = set()
341
+ for scenario_name in scenario_folders:
342
+ tb_path = parquet_base / scenario_name / "timeline_breaks.parquet"
343
+ if tb_path.exists():
344
+ try:
345
+ tb_df = read_lean_parquet(tb_path)
346
+ if "time" in tb_df.columns:
347
+ break_times.update(tb_df["time"].astype(str))
348
+ except Exception:
349
+ pass
350
+ if break_times:
351
+ bt_df = pd.DataFrame({"break_time": sorted(break_times)})
352
+ write_lean_parquet(bt_df, output_dir / "timeline_breaks.parquet", index=False)
353
+
354
+ written_count = sum(
355
+ 1 for df in combined_dfs.values()
356
+ if not df.empty and isinstance(df.columns, pd.MultiIndex)
357
+ )
358
+ print(
359
+ f"Wrote comparison parquets for {len(scenario_folders)} scenarios "
360
+ f"({written_count} variables) to: {output_dir}"
361
+ )
362
+
363
+ # 7. Compute plot plans and availability for the comparison data.
364
+ # Read the merged single-mode YAML and derive comparison configs from
365
+ # each entry's ``scenario_rule`` (or an explicit
366
+ # ``comparison_overrides.map_dimensions_for_plots``); entries with
367
+ # neither have no comparison rendering and are dropped.
368
+ try:
369
+ import yaml
370
+ from flextool.plot_outputs.orchestrator import compute_all_plot_plans
371
+ from flextool.plot_outputs.color_template import resolve_plot_settings_path
372
+ from flextool.plot_outputs.config import flatten_new_format
373
+ from flextool.scenario_comparison.orchestrator import (
374
+ _derive_comparison_settings,
375
+ )
376
+ from flextool.gui.project_utils import get_projects_dir
377
+
378
+ config_path = get_projects_dir().parent / "templates" / "default_plots.yaml"
379
+ if config_path.is_file():
380
+ with open(config_path, "r", encoding="utf-8") as f:
381
+ settings = yaml.safe_load(f)
382
+ plot_settings = _derive_comparison_settings(
383
+ flatten_new_format(settings.get("plots", {}))
384
+ )
385
+ bt = break_times if break_times else None
386
+ compute_all_plot_plans(
387
+ combined_dfs, plot_settings, output_dir,
388
+ plot_rows=(0, 167), break_times=bt,
389
+ strip_scenario_level=False,
390
+ color_path=resolve_plot_settings_path(project_path),
391
+ )
392
+ except Exception as exc:
393
+ import logging
394
+ logging.getLogger(__name__).warning(
395
+ "Comparison plot plan computation failed (non-fatal): %s", exc
396
+ )
397
+
398
+ # 7. Return the output directory
399
+ return output_dir