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,1141 @@
1
+ """
2
+ Plot Outputs Orchestrator
3
+ =========================
4
+
5
+ Entry point for the plot_outputs module.
6
+
7
+ plot_dict_of_dataframes() — public entry point
8
+ - Parses settings into PlotConfig
9
+ - Applies dimension rules (sum, average, chunk-average, stack, unstack)
10
+ - Splits data into per-file chunks via _plan_file_splits()
11
+ - Dispatches to plot_lines or plot_bars
12
+
13
+ prepare_plot_data() — public, returns Figures without saving
14
+ - Processes one DataFrame + PlotConfig through dimension rules
15
+ - Dispatches to build_*_figures() functions
16
+ - Returns list of (filename_stem, Figure) pairs
17
+ """
18
+ import os
19
+ from pathlib import Path
20
+ import pandas as pd
21
+ import matplotlib
22
+ matplotlib.use('Agg') # Use non-interactive backend
23
+ import matplotlib.pyplot as plt
24
+ import logging
25
+ from flextool.plot_outputs.format_helpers import (
26
+ generate_split_filename, split_into_chunks, _chunk_average_df,
27
+ insert_timeline_breaks,
28
+ )
29
+ from flextool.plot_outputs.config import PlotConfig, PLOT_FIELD_NAMES, _is_single_config, default_ylabel_for, flatten_new_format
30
+ from flextool.plot_outputs.axis_helpers import _normalize_axis_bounds
31
+ from flextool.plot_outputs.plot_bars import build_bar_figures
32
+ from flextool.plot_outputs.plot_lines import build_line_figures, build_stack_figures
33
+ from flextool.plot_outputs.color_template import load_color_template
34
+
35
+ logger = logging.getLogger(__name__)
36
+ logging.getLogger('matplotlib.category').disabled = True
37
+ matplotlib.rcParams['axes.spines.top'] = False
38
+ matplotlib.rcParams['axes.spines.right'] = False
39
+
40
+
41
+ def _plan_file_splits(
42
+ all_subs: list,
43
+ all_items: list,
44
+ subplot_levels: list[int],
45
+ max_items_per_plot: int,
46
+ max_subplots_per_file: int,
47
+ ) -> list:
48
+ """Plan how to split data into files.
49
+
50
+ With subplot_levels: returns a list of file_chunks, each a list of
51
+ (sub, item_chunk) pairs (one pair per effective subplot in that file).
52
+
53
+ Without subplot_levels: returns a list of item_chunks, one per file.
54
+ """
55
+ needs_item_split = len(all_items) > max_items_per_plot
56
+ item_chunks = (
57
+ list(split_into_chunks(all_items, max_items_per_plot))
58
+ if needs_item_split else [all_items]
59
+ )
60
+
61
+ if subplot_levels:
62
+ # Combine all (sub, item_chunk) pairs, then split into files
63
+ effective = [(sub, ic) for sub in all_subs for ic in item_chunks]
64
+ if len(effective) > max_subplots_per_file:
65
+ return list(split_into_chunks(effective, max_subplots_per_file))
66
+ return [effective]
67
+ else:
68
+ # Each item_chunk becomes its own file
69
+ return item_chunks
70
+
71
+
72
+ def _extract_period_weights(results_dict: dict):
73
+ """Pull years_represented weights out of results_dict if present.
74
+
75
+ Returns a Series (period → weight) when the frame has a single column,
76
+ or a DataFrame (period × scenario) when the frame carries scenario in
77
+ its column MultiIndex. Returns None when the key is absent or empty.
78
+ """
79
+ df = results_dict.get('years_represented__d')
80
+ if df is None or df.empty:
81
+ return None
82
+ # Drop the 'scenario' level on the row index if present (per-scenario parquets).
83
+ if isinstance(df.index, pd.MultiIndex) and 'scenario' in df.index.names:
84
+ df = df.droplevel('scenario')
85
+ if isinstance(df.columns, pd.MultiIndex) and 'scenario' in df.columns.names:
86
+ # Collapse the inner 'years_represented' level; keep scenario as cols.
87
+ other_levels = [n for n in df.columns.names if n != 'scenario']
88
+ wide = df.droplevel(other_levels, axis=1) if other_levels else df
89
+ # Deduplicate identical scenario columns from any extra row-level leftovers.
90
+ wide = wide.loc[:, ~wide.columns.duplicated()]
91
+ return wide
92
+ if df.shape[1] == 1:
93
+ return df.iloc[:, 0]
94
+ return df
95
+
96
+
97
+ def _weight_rows_by_period(df: pd.DataFrame, period_weights, period_level: int) -> pd.DataFrame:
98
+ """Multiply each row of df by its period weight.
99
+
100
+ ``period_weights`` is either a Series (period → weight) or a DataFrame
101
+ (index=period, columns=scenario). When ``df`` has a 'scenario' column
102
+ MultiIndex level, a DataFrame of weights is applied per-scenario column;
103
+ otherwise a single Series (or first column of a DataFrame) is broadcast
104
+ across all columns.
105
+ """
106
+ if period_weights is None:
107
+ return df
108
+ period_vals = df.index.get_level_values(period_level)
109
+ col_is_multi = isinstance(df.columns, pd.MultiIndex)
110
+ if isinstance(period_weights, pd.DataFrame):
111
+ if col_is_multi and 'scenario' in df.columns.names:
112
+ scens = df.columns.get_level_values('scenario')
113
+ # Aligned weight matrix: rows match df.index via period lookup, columns match df.columns.
114
+ weight_rows = period_weights.reindex(period_vals)
115
+ weight_rows = weight_rows.reindex(columns=scens)
116
+ weight_rows.index = df.index
117
+ weight_rows.columns = df.columns
118
+ return df * weight_rows
119
+ # No scenario column level — take the first (or only) weight column.
120
+ w_series = period_weights.iloc[:, 0]
121
+ else:
122
+ w_series = period_weights
123
+ w_aligned = w_series.reindex(period_vals)
124
+ w_aligned.index = df.index
125
+ return df.mul(w_aligned, axis=0)
126
+
127
+
128
+ def _sum_period_weights(period_weights, periods) -> float:
129
+ """Sum weights across the given periods (scalar); used as 'z' denominator."""
130
+ if period_weights is None:
131
+ return float(len(periods))
132
+ if isinstance(period_weights, pd.DataFrame):
133
+ w = period_weights.iloc[:, 0]
134
+ else:
135
+ w = period_weights
136
+ return float(w.reindex(periods).sum())
137
+
138
+
139
+ def _apply_dimension_rules(
140
+ df_orig: pd.DataFrame,
141
+ cfg: PlotConfig,
142
+ plot_rows: tuple[int, int],
143
+ period_weights=None,
144
+ ) -> tuple[pd.DataFrame, str, str, list[str], list[str]] | None:
145
+ """Apply dimension rules from PlotConfig to a DataFrame.
146
+
147
+ ``period_weights`` provides the per-period years_represented values used
148
+ by the 'y' (weighted sum) and 'z' (weighted average) rules. May be None
149
+ when the run carries no years_represented — every period then represents
150
+ one year (the model default), i.e. unit weights: 'y' is a plain sum and
151
+ 'z' a plain mean. This is a valid configuration, not a degradation, so
152
+ no warning is emitted.
153
+
154
+ Returns (df, rules, chart_type, summed_dimensions, averaged_dimensions)
155
+ or None if config is invalid / should be skipped.
156
+ """
157
+ map_dims = cfg.map_dimensions_for_plots
158
+ if not map_dims or len(map_dims) < 2:
159
+ return None
160
+ index_types, rules = map_dims[0], map_dims[1]
161
+ if not rules:
162
+ return None
163
+ rules = rules.replace('_', '')
164
+
165
+ if index_types is None:
166
+ return None
167
+ parts = index_types.split('_')
168
+ if len(parts) == 2:
169
+ df_index_levels, df_columns_levels = parts
170
+ else:
171
+ raise ValueError(
172
+ 'map_dimensions_for_plots first element should '
173
+ 'contain one underscore to separate row and column index indicators'
174
+ )
175
+
176
+ if 't' in rules and 'i' not in rules:
177
+ chart_type = 'time'
178
+ if cfg.full_timeline:
179
+ df = df_orig.copy()
180
+ else:
181
+ df = df_orig.iloc[plot_rows[0]:plot_rows[1]].copy()
182
+ elif 'i' in rules:
183
+ chart_type = 'time'
184
+ df = df_orig.copy()
185
+ else:
186
+ chart_type = 'bar'
187
+ df = df_orig.copy()
188
+
189
+ # Normalise to always-MultiIndex at entry to simplify downstream handling
190
+ if not isinstance(df.index, pd.MultiIndex):
191
+ df.index = pd.MultiIndex.from_arrays([df.index], names=[df.index.name])
192
+ if not isinstance(df.columns, pd.MultiIndex):
193
+ df.columns = pd.MultiIndex.from_arrays([df.columns], names=[df.columns.name])
194
+
195
+ nr_row_levels = df.index.nlevels
196
+ nr_col_levels = df.columns.nlevels
197
+ if len(rules) != nr_row_levels + nr_col_levels:
198
+ raise ValueError(
199
+ f"Number of plot_type rules different from the number of index + "
200
+ f"column levels in the dataframe. {cfg.plot_name}"
201
+ )
202
+ if len(df_columns_levels) != nr_col_levels:
203
+ logging.warning(
204
+ "Plot config '%s': map_dimensions_for_plots column part has %d "
205
+ "levels but DataFrame has %d column levels — skipping",
206
+ cfg.plot_name, len(df_columns_levels), nr_col_levels,
207
+ )
208
+ return None
209
+
210
+ levels_to_sort = [i for i, c in enumerate(df_columns_levels) if c in ('e', 'g')]
211
+ if levels_to_sort:
212
+ df = df.sort_index(axis=1, level=levels_to_sort, sort_remaining=False)
213
+
214
+ # Track which dimensions were summed/averaged for plot title
215
+ summed_dimensions: list[str] = []
216
+ averaged_dimensions: list[str] = []
217
+
218
+ # 'y' = period-weighted sum, 'z' = period-weighted average. Both
219
+ # pre-multiply rows by years_represented[period] before the collapse;
220
+ # 'z' additionally divides by the sum of weights after the collapse.
221
+ # Only supported when the row level being collapsed is named 'period'.
222
+ weight_row_levels = [i for i, c in enumerate(rules[:nr_row_levels]) if c in ('y', 'z')]
223
+ mean_weight_level = None # for post-collapse division (z only)
224
+ periods_for_denom = None
225
+ if weight_row_levels:
226
+ valid_levels = [i for i in weight_row_levels if df.index.names[i] == 'period']
227
+ invalid = [i for i in weight_row_levels if i not in valid_levels]
228
+ for i in invalid:
229
+ # Benign: 'y'/'z' weight by years_represented[period] — when
230
+ # the row level being collapsed isn't actually 'period' (e.g.
231
+ # the data has already been aggregated to a single row named
232
+ # 'sum', or the producer emitted a different row label), the
233
+ # function falls back to an unweighted sum, which is the
234
+ # right behaviour for non-period dimensions anyway. Logged
235
+ # at DEBUG to keep the user-facing output quiet — the
236
+ # fallback is correct, not an error.
237
+ logger.debug(
238
+ "Plot config '%s': rule '%s' only applies to the 'period' "
239
+ "row level, got '%s' — falling back to unweighted.",
240
+ cfg.plot_name, rules[i], df.index.names[i],
241
+ )
242
+ if valid_levels:
243
+ # Missing years_represented means every period represents one
244
+ # year (the model default) — all weights are 1.0, a valid choice
245
+ # rather than a degradation. The weight helpers already treat
246
+ # period_weights=None as unit weights, so 'y' collapses to a
247
+ # plain sum and 'z' to a plain mean (sum / number of periods);
248
+ # no special-case and no warning are needed.
249
+ period_level = valid_levels[0]
250
+ periods_for_denom = df.index.get_level_values(period_level).unique()
251
+ df = _weight_rows_by_period(df, period_weights, period_level)
252
+ if rules[period_level] == 'z':
253
+ mean_weight_level = period_level
254
+ # Rewrite 'y'/'z' to 'm' so the existing sum branch collapses them.
255
+ for i in weight_row_levels:
256
+ rules = rules[:i] + 'm' + rules[i + 1:]
257
+
258
+ # Sum row levels marked 'm' (includes 'y'/'z' rewritten above)
259
+ sum_row_levels = [i for i, char in enumerate(rules[:nr_row_levels]) if char == 'm']
260
+ if sum_row_levels:
261
+ summed_dimensions.extend(df.index.names[i] for i in sum_row_levels)
262
+ keep_levels = [i for i in range(nr_row_levels) if i not in sum_row_levels]
263
+ if len(keep_levels) > 0:
264
+ df = df.groupby(level=keep_levels).sum()
265
+ for i in sum_row_levels:
266
+ rules = rules[:i] + rules[i + 1:]
267
+ else:
268
+ df = df.sum(axis=0).to_frame().T
269
+ df.index = ['']
270
+ df.index.name = 'sum'
271
+
272
+ # For 'z': divide the (weighted) sum by the total weight → weighted average.
273
+ if mean_weight_level is not None:
274
+ denom = _sum_period_weights(period_weights, periods_for_denom)
275
+ if denom != 0:
276
+ df = df / denom
277
+
278
+ # Sum column levels marked 'm'
279
+ nr_column_levels = df.columns.nlevels
280
+ sum_column_levels = [i for i, char in enumerate(rules[df.index.nlevels:]) if char == 'm']
281
+ if sum_column_levels:
282
+ summed_dimensions.extend(df.columns.names[i] for i in sum_column_levels)
283
+ keep_levels = [i for i in range(nr_column_levels) if i not in sum_column_levels]
284
+ if len(keep_levels) > 0:
285
+ df = df.T.groupby(level=keep_levels).sum().T
286
+ for i in sum_column_levels:
287
+ rules = rules[:i + df.index.nlevels] + rules[i + 1 + df.index.nlevels:]
288
+ else:
289
+ df = df.sum(axis=1).to_frame()
290
+ df.columns = ['']
291
+ df.columns.name = 'sum'
292
+
293
+ nr_row_levels = df.index.nlevels
294
+ nr_column_levels = df.columns.nlevels
295
+
296
+ # Average row levels marked 'a'
297
+ mean_row_levels = [i for i, char in enumerate(rules[:nr_row_levels]) if char == 'a']
298
+ if mean_row_levels:
299
+ averaged_dimensions.extend(df.index.names[i] for i in mean_row_levels)
300
+ keep_levels = [i for i in range(nr_row_levels) if i not in mean_row_levels]
301
+ if len(keep_levels) > 1:
302
+ df = df.groupby(level=keep_levels).mean()
303
+ for i in mean_row_levels:
304
+ rules = rules[:i] + rules[i + 1:]
305
+ else:
306
+ df = df.mean(axis=0).to_frame().T
307
+ df.index = ['']
308
+ df.index.name = 'mean'
309
+
310
+ # Average column levels marked 'a'
311
+ mean_column_levels = [i for i, char in enumerate(rules[nr_row_levels:]) if char == 'a']
312
+ if mean_column_levels:
313
+ averaged_dimensions.extend(df.columns.names[i] for i in mean_column_levels)
314
+ keep_levels = [i for i in range(nr_column_levels) if i not in mean_column_levels]
315
+ if len(keep_levels) > 1:
316
+ df = df.T.groupby(level=keep_levels).mean().T
317
+ for i in mean_column_levels:
318
+ rules = rules[:i + df.index.nlevels] + rules[i + 1 + df.index.nlevels:]
319
+ else:
320
+ df = df.mean(axis=1).to_frame()
321
+ df.columns = ['']
322
+ df.columns.name = 'mean'
323
+
324
+ nr_row_levels = df.index.nlevels
325
+ nr_column_levels = df.columns.nlevels
326
+
327
+ # Chunk-average 'i' row levels if time_average_duration is set
328
+ i_positions = [pos for pos, char in enumerate(rules[:nr_row_levels]) if char == 'i']
329
+ if i_positions and cfg.time_average_duration:
330
+ i_pos = i_positions[0]
331
+ chunk_size = int(cfg.time_average_duration)
332
+ other_levels = [lv for lv in range(nr_row_levels) if lv != i_pos]
333
+ if other_levels and nr_row_levels > 1:
334
+ chunk_parts = []
335
+ group_level = other_levels[0] if len(other_levels) == 1 else other_levels
336
+ for group_key, group_df in df.groupby(level=group_level):
337
+ flat = group_df.droplevel(other_levels)
338
+ averaged = _chunk_average_df(flat, chunk_size)
339
+ if not isinstance(group_key, tuple):
340
+ group_key = (group_key,)
341
+ new_tuples = []
342
+ for idx_val in averaged.index:
343
+ row = [None] * nr_row_levels
344
+ row[i_pos] = idx_val
345
+ for j, lv in enumerate(other_levels):
346
+ row[lv] = group_key[j]
347
+ new_tuples.append(tuple(row))
348
+ averaged.index = pd.MultiIndex.from_tuples(
349
+ new_tuples, names=df.index.names
350
+ )
351
+ chunk_parts.append(averaged)
352
+ df = pd.concat(chunk_parts)
353
+ else:
354
+ df = _chunk_average_df(df, chunk_size)
355
+
356
+ if df.empty or len(df) == 0:
357
+ return None
358
+
359
+ # Track level names before rearrangement to rebuild rules after stack/unstack
360
+ level_names_before = list(df.index.names) + list(df.columns.names)
361
+ name_to_rule = dict(zip(level_names_before, rules))
362
+
363
+ # Sentinel marker for the spurious column level introduced by
364
+ # ``to_frame()`` when ``stack`` / ``unstack`` collapses to a Series.
365
+ # We tag the level name and drop the level once a real col level
366
+ # appears alongside (i.e. once an unstack adds one). See the
367
+ # block after the unstack below.
368
+ _SPURIOUS_COL = '__flextool_spurious_col__'
369
+
370
+ # Move bar/line levels from columns to index (batch to avoid Cartesian product)
371
+ nr_row = df.index.nlevels
372
+ col_levels_to_stack = [i - nr_row for i, c in enumerate(rules)
373
+ if c in ('b', 't', 'i') and i >= nr_row]
374
+ if col_levels_to_stack:
375
+ df = df.stack(col_levels_to_stack, future_stack=True)
376
+ if isinstance(df, pd.Series):
377
+ # Stacking the only column level collapses to a Series.
378
+ # ``to_frame()`` brings it back to a DataFrame but with a
379
+ # default-named placeholder column level — tag it so we can
380
+ # drop it later if a real col level is added by the unstack
381
+ # below. Without this, the placeholder lingers and breaks
382
+ # the rules-string-vs-level-count invariant downstream
383
+ # (configs like ``[d_s, s_b]`` end up with a "phantom"
384
+ # extra col level that prevents stacks from rendering).
385
+ df = df.to_frame().rename_axis(columns=_SPURIOUS_COL)
386
+
387
+ # Rebuild rules to match level order after stacking
388
+ level_names_mid = list(df.index.names) + list(df.columns.names)
389
+ if (len(set(level_names_before)) == len(level_names_before)
390
+ and all(n is not None for n in level_names_before)):
391
+ try:
392
+ rules = ''.join(name_to_rule[n] for n in level_names_mid)
393
+ except KeyError:
394
+ pass
395
+
396
+ # Move column-type levels from row index to columns (batch to avoid Cartesian product)
397
+ row_levels_to_unstack = [i for i, c in enumerate(rules[:df.index.nlevels])
398
+ if c in ('u', 'g', 's', 'l', 'e', 'f')]
399
+ if row_levels_to_unstack:
400
+ df = df.unstack(row_levels_to_unstack)
401
+ if isinstance(df, pd.Series):
402
+ df = df.to_frame().rename_axis(columns=_SPURIOUS_COL)
403
+
404
+ # Drop the spurious col level introduced by Phase 1's ``to_frame()``
405
+ # — but only if a real col level was added alongside (otherwise the
406
+ # data has no col index at all and we'd leave it as 0-level, which
407
+ # ``rebuild rules`` and the figure builder don't expect).
408
+ spurious_cols = [
409
+ i for i, name in enumerate(df.columns.names) if name == _SPURIOUS_COL
410
+ ]
411
+ if spurious_cols and df.columns.nlevels > len(spurious_cols):
412
+ for i in reversed(spurious_cols):
413
+ df = df.droplevel(i, axis=1)
414
+
415
+ # The stack/unstack pair above fabricates rows/columns for every
416
+ # combination of the stacked levels' unique values, including
417
+ # (unit, source) — or analogous entity-pair — combos that did not
418
+ # exist in the input parquet. Those phantom cells arrive as all-NaN
419
+ # rows / columns. Drop them so pagination, drawing, and the
420
+ # skip_data_with_only_zeroes flag see only the real combinations.
421
+ # This is a no-op for already-dense data.
422
+ df = df.dropna(how='all', axis=0).dropna(how='all', axis=1)
423
+
424
+ # Rebuild rules to match actual level order after stack/unstack
425
+ level_names_after = list(df.index.names) + list(df.columns.names)
426
+ if (len(set(level_names_before)) == len(level_names_before)
427
+ and all(n is not None for n in level_names_before)):
428
+ try:
429
+ rules = ''.join(name_to_rule[n] for n in level_names_after)
430
+ except KeyError:
431
+ pass # names changed unexpectedly, keep original rules
432
+
433
+ # Drop any remaining 'm'/'a' row levels
434
+ sum_mean_row_levels = [i for i, char in enumerate(rules[:df.index.nlevels])
435
+ if char in ('m', 'a')]
436
+ if df.index.nlevels - len(sum_mean_row_levels) > 0:
437
+ for i in reversed(sum_mean_row_levels):
438
+ df = df.droplevel(i)
439
+ rules = rules[:i] + rules[i + 1:]
440
+
441
+ # Drop any remaining 'm'/'a' column levels
442
+ sum_mean_column_levels = [i for i, char in enumerate(rules[df.index.nlevels:])
443
+ if char in ('m', 'a')]
444
+ if df.columns.nlevels - len(sum_mean_column_levels) > 0:
445
+ for i in reversed(sum_mean_column_levels):
446
+ df = df.droplevel(i, axis=1)
447
+ rules = rules[:i + df.index.nlevels] + rules[i + 1 + df.index.nlevels:]
448
+
449
+ return df, rules, chart_type, summed_dimensions, averaged_dimensions
450
+
451
+
452
+ def _resolve_shared_axis_bounds(
453
+ df: pd.DataFrame,
454
+ axis_bounds,
455
+ stack_levels: list[int],
456
+ subplot_levels: list[int],
457
+ always_include_zero: bool,
458
+ ):
459
+ """Resolve 'shared' axis_bounds to actual min/max tuple list.
460
+
461
+ For stacked charts the visible bar/area height at each x is the
462
+ sum over stack_levels only. Other remaining column levels
463
+ (expand_axis, grouped_bars, file_levels, scenario in comparison
464
+ mode) correspond to distinct bars/series, not stack components, so
465
+ they must be grouped by — not summed across — when computing the
466
+ stacked max. Collapsing them with sum(axis=1) would inflate the
467
+ bound by their cardinality.
468
+ """
469
+ if axis_bounds != 'shared':
470
+ return axis_bounds
471
+ numeric_df = df.select_dtypes(include='number')
472
+ if numeric_df.empty:
473
+ return None
474
+
475
+ def _stack_extremes(frame: pd.DataFrame) -> tuple[float, float]:
476
+ """Max of stacked positives and min of stacked negatives."""
477
+ if isinstance(frame.columns, pd.MultiIndex):
478
+ stack_names = [frame.columns.names[i] for i in stack_in_frame]
479
+ non_stack = [n for n in frame.columns.names if n not in stack_names]
480
+ if non_stack:
481
+ pos = frame.clip(lower=0).T.groupby(level=non_stack).sum().T
482
+ neg = frame.clip(upper=0).T.groupby(level=non_stack).sum().T
483
+ else:
484
+ pos = frame.clip(lower=0).sum(axis=1).to_frame()
485
+ neg = frame.clip(upper=0).sum(axis=1).to_frame()
486
+ else:
487
+ pos = frame.clip(lower=0)
488
+ neg = frame.clip(upper=0)
489
+ return float(pos.max().max()), float(neg.min().min())
490
+
491
+ if stack_levels and subplot_levels:
492
+ global_max = float('-inf')
493
+ global_min = float('inf')
494
+ # Translate stack_levels (positions in numeric_df.columns) to the
495
+ # corresponding positions in sub_df.columns after xs removed the
496
+ # subplot level. Positions above the subplot shift down by one.
497
+ sub_pos = subplot_levels[0]
498
+ stack_in_frame = [
499
+ (i - 1) if i > sub_pos else i
500
+ for i in stack_levels
501
+ if i != sub_pos
502
+ ]
503
+ for sub_val in (
504
+ numeric_df.columns.get_level_values(subplot_levels[0]).unique()
505
+ if len(subplot_levels) == 1
506
+ else [None]
507
+ ):
508
+ if sub_val is not None:
509
+ try:
510
+ sub_df = numeric_df.xs(sub_val, level=subplot_levels[0], axis=1)
511
+ except KeyError:
512
+ continue
513
+ else:
514
+ sub_df = numeric_df
515
+ if isinstance(sub_df, pd.Series):
516
+ sub_df = sub_df.to_frame()
517
+ pos_max, neg_min = _stack_extremes(sub_df)
518
+ global_max = max(global_max, pos_max)
519
+ global_min = min(global_min, neg_min)
520
+ elif stack_levels:
521
+ stack_in_frame = list(stack_levels)
522
+ global_max, global_min = _stack_extremes(numeric_df)
523
+ else:
524
+ global_min = float(numeric_df.min().min())
525
+ global_max = float(numeric_df.max().max())
526
+ if always_include_zero:
527
+ global_min = min(global_min, 0.0)
528
+ global_max = max(global_max, 0.0)
529
+ return [(global_min, global_max)]
530
+
531
+
532
+ def _process_file_member(
533
+ df: pd.DataFrame,
534
+ file_member,
535
+ file_levels: list[int],
536
+ plot_title: str,
537
+ grouped_bar_levels: list[int],
538
+ stack_levels: list[int],
539
+ expand_axis_levels: list[int],
540
+ subplot_levels: list[int],
541
+ line_levels: list[int],
542
+ ) -> tuple[pd.DataFrame, str, str | None, list[int], list[int], list[int], list[int], list[int]] | None:
543
+ """Filter df to a file member and shift level indices.
544
+
545
+ Returns (df_fm, effective_plot_name, member_str,
546
+ fm_grouped_bar_levels, fm_stack_levels, fm_expand_axis_levels,
547
+ fm_subplot_levels, fm_line_levels)
548
+ or None if the filtered df is empty.
549
+ """
550
+ if file_member is not None:
551
+ df_fm = df.copy()
552
+ if len(file_levels) == 1:
553
+ level_name = df.columns.names[file_levels[0]]
554
+ mask = df_fm.columns.get_level_values(level_name) == file_member
555
+ df_fm = df_fm.loc[:, mask]
556
+ df_fm = df_fm.droplevel(file_levels[0], axis=1)
557
+ else:
558
+ col_frame = df_fm.columns.to_frame().iloc[:, file_levels]
559
+ mask = col_frame.apply(tuple, axis=1) == file_member
560
+ df_fm = df_fm.loc[:, mask.values]
561
+ for lvl in sorted(file_levels, reverse=True):
562
+ df_fm = df_fm.droplevel(lvl, axis=1)
563
+
564
+ if not isinstance(df_fm.columns, pd.MultiIndex):
565
+ df_fm.columns = pd.MultiIndex.from_arrays(
566
+ [df_fm.columns], names=[df_fm.columns.name]
567
+ )
568
+
569
+ if df_fm.empty:
570
+ return None
571
+
572
+ member_str = (
573
+ str(file_member) if not isinstance(file_member, tuple)
574
+ else '_'.join(str(v) for v in file_member)
575
+ )
576
+ effective_plot_name = f'{plot_title} \u2014 {member_str}'
577
+
578
+ def _shift(lvls):
579
+ return [
580
+ lvl - sum(1 for fl in file_levels if fl < lvl)
581
+ for lvl in lvls
582
+ ]
583
+ fm_grouped_bar_levels = _shift(grouped_bar_levels)
584
+ fm_stack_levels = _shift(stack_levels)
585
+ fm_expand_axis_levels = _shift(expand_axis_levels)
586
+ fm_subplot_levels = _shift(subplot_levels)
587
+ fm_line_levels = _shift(line_levels)
588
+ else:
589
+ df_fm = df
590
+ effective_plot_name = plot_title
591
+ member_str = None
592
+ fm_grouped_bar_levels = grouped_bar_levels
593
+ fm_stack_levels = stack_levels
594
+ fm_expand_axis_levels = expand_axis_levels
595
+ fm_subplot_levels = subplot_levels
596
+ fm_line_levels = line_levels
597
+
598
+ return (df_fm, effective_plot_name, member_str,
599
+ fm_grouped_bar_levels, fm_stack_levels, fm_expand_axis_levels,
600
+ fm_subplot_levels, fm_line_levels)
601
+
602
+
603
+ def reattach_file_level_for_color(
604
+ df_fm, file_member, file_level_names, entity_class,
605
+ ):
606
+ """Re-add the file-split level when a simple bar is colored by it.
607
+
608
+ A ``f``-role entity (e.g. NodeGroup indicators — one file per node group)
609
+ is dropped by ``_process_file_member`` before the bar renderer sees it,
610
+ so all bars in a file would render in the default color. When
611
+ ``entity_class`` resolves to a file level, re-attach the file member's
612
+ value as a constant (innermost) column level so the simple-bar color
613
+ machinery treats it like any surviving level: the whole file gets that
614
+ group's color — same as a subplot. No-op otherwise.
615
+ """
616
+ if file_member is None or not entity_class or not file_level_names:
617
+ return df_fm
618
+ from flextool.plot_outputs.plan import resolve_color_bar_level
619
+ if not resolve_color_bar_level(file_level_names, entity_class):
620
+ return df_fm
621
+ vals = list(file_member) if isinstance(file_member, tuple) else [file_member]
622
+ if len(vals) != len(file_level_names):
623
+ return df_fm
624
+ cols = df_fm.columns
625
+ if not isinstance(cols, pd.MultiIndex):
626
+ cols = pd.MultiIndex.from_arrays([cols], names=[cols.name])
627
+ arrays = [cols.get_level_values(i) for i in range(cols.nlevels)]
628
+ names = list(cols.names)
629
+ for nm, v in zip(file_level_names, vals):
630
+ arrays.append([v] * len(df_fm.columns))
631
+ names.append(nm)
632
+ df_fm = df_fm.copy()
633
+ df_fm.columns = pd.MultiIndex.from_arrays(arrays, names=names)
634
+ return df_fm
635
+
636
+
637
+ def prepare_plot_data(
638
+ df: pd.DataFrame,
639
+ plot_config: PlotConfig,
640
+ plot_name: str = '',
641
+ plot_rows: tuple[int, int] = (0, 167),
642
+ break_times: set[str] | None = None,
643
+ only_file_index: int | None = None,
644
+ period_weights=None,
645
+ color_path: Path | None = None,
646
+ ) -> tuple[list[tuple[str, plt.Figure]], int]:
647
+ """Process one result DataFrame through dimension rules and build Figures.
648
+
649
+ Returns (figures, total_file_count) where figures is a list of
650
+ (filename_stem, Figure) pairs -- one per file split.
651
+ When only_file_index is None, figures has all file splits.
652
+ When only_file_index is set, figures has at most 1 element.
653
+ Figures are NOT saved or closed; the caller is responsible for that.
654
+ """
655
+ if df.empty:
656
+ return [], 0
657
+
658
+ cfg = plot_config
659
+ result_name = plot_name or cfg.plot_name or 'plot'
660
+ axis_bounds = _normalize_axis_bounds(cfg.axis_bounds)
661
+
662
+ # Load color template once so build_*_figures can apply template colors
663
+ # consistently (matching what plan.py's _compute_*_plan path does).
664
+ color_tmpl = load_color_template(color_path)
665
+ color_category = cfg.color_category if cfg is not None else None
666
+ color_entity_class = cfg.color_entity_class if cfg is not None else None
667
+
668
+ dim_result = _apply_dimension_rules(df, cfg, plot_rows, period_weights=period_weights)
669
+ if dim_result is None:
670
+ return [], 0
671
+ df_processed, rules, chart_type, summed_dimensions, averaged_dimensions = dim_result
672
+
673
+ # Level indices for each plot role (in the column MultiIndex)
674
+ col_rules = rules[df_processed.index.nlevels:]
675
+ grouped_bar_levels = [i for i, c in enumerate(col_rules) if c == 'g']
676
+ stack_levels = [i for i, c in enumerate(col_rules) if c == 's']
677
+ expand_axis_levels = [i for i, c in enumerate(col_rules) if c == 'e']
678
+ subplot_levels = [i for i, c in enumerate(col_rules) if c == 'u']
679
+ line_levels = [i for i, c in enumerate(col_rules) if c == 'l']
680
+ file_levels = [i for i, c in enumerate(col_rules) if c == 'f']
681
+
682
+ # Build plot title with summed/averaged info
683
+ plot_title = result_name
684
+ if summed_dimensions:
685
+ dim_str = "', '".join(str(d) for d in summed_dimensions)
686
+ plot_title = f"{plot_title} ('{dim_str}' summed)"
687
+ if averaged_dimensions:
688
+ dim_str = "', '".join(str(d) for d in averaged_dimensions)
689
+ plot_title = f"{plot_title} ('{dim_str}' averaged)"
690
+
691
+ # Determine file-dimension members (outer split)
692
+ if file_levels:
693
+ if len(file_levels) == 1:
694
+ all_file_members: list = (
695
+ df_processed.columns.get_level_values(file_levels[0]).unique().tolist()
696
+ )
697
+ else:
698
+ fm_df = (
699
+ df_processed.columns.to_frame().iloc[:, file_levels].drop_duplicates()
700
+ )
701
+ all_file_members = [tuple(row) for row in fm_df.values]
702
+ else:
703
+ all_file_members = [None]
704
+
705
+ # Resolve shared axis bounds
706
+ axis_bounds = _resolve_shared_axis_bounds(
707
+ df_processed, axis_bounds, stack_levels, subplot_levels,
708
+ cfg.always_include_zero_in_axis,
709
+ )
710
+
711
+ figures: list[tuple[str, plt.Figure]] = []
712
+ total_file_count = 0
713
+
714
+ for file_member in all_file_members:
715
+ fm_result = _process_file_member(
716
+ df_processed, file_member, file_levels, plot_title,
717
+ grouped_bar_levels, stack_levels, expand_axis_levels,
718
+ subplot_levels, line_levels,
719
+ )
720
+ if fm_result is None:
721
+ continue
722
+ (df_fm, effective_plot_name, member_str,
723
+ fm_grouped_bar_levels, fm_stack_levels, fm_expand_axis_levels,
724
+ fm_subplot_levels, fm_line_levels) = fm_result
725
+
726
+ # Simple bar colored by the file-split entity: restore the file
727
+ # member's value so the whole file gets that group's color.
728
+ if chart_type == 'bar' and color_entity_class:
729
+ file_level_names = [df_processed.columns.names[i] for i in file_levels]
730
+ df_fm = reattach_file_level_for_color(
731
+ df_fm, file_member, file_level_names, color_entity_class)
732
+
733
+ # Drop near-zero columns and rows when skip_data_with_only_zeroes is enabled
734
+ if cfg.skip_data_with_only_zeroes:
735
+ df_fm = df_fm.loc[:, (df_fm.abs() > 1e-6).any()]
736
+ if chart_type == 'bar':
737
+ df_fm = df_fm.loc[(df_fm.abs() > 1e-6).any(axis=1)]
738
+ if df_fm.empty:
739
+ continue
740
+
741
+ # Apply unit conversion multiplier
742
+ if cfg.multiply_by is not None:
743
+ df_fm = df_fm * cfg.multiply_by
744
+
745
+ # Insert NaN rows at timeline breaks for visual gaps
746
+ if chart_type == 'time' and break_times:
747
+ df_fm = insert_timeline_breaks(df_fm, break_times)
748
+
749
+ # Determine max items
750
+ default_max_items = 10
751
+ max_items = cfg.max_items_per_plot if cfg.max_items_per_plot is not None else default_max_items
752
+
753
+ if chart_type == 'bar':
754
+ figs, count = build_bar_figures(
755
+ df_fm, effective_plot_name, '',
756
+ fm_stack_levels, fm_expand_axis_levels,
757
+ fm_subplot_levels, fm_grouped_bar_levels,
758
+ legend_position=cfg.legend,
759
+ subplots_per_row=cfg.subplots_per_row,
760
+ xlabel=cfg.xlabel, ylabel=cfg.ylabel,
761
+ bar_orientation=cfg.bar_orientation,
762
+ base_bar_length=cfg.base_length,
763
+ value_label=cfg.value_label,
764
+ axis_bounds=axis_bounds,
765
+ axis_tick_format=cfg.axis_tick_format,
766
+ always_include_zero_in_axis=cfg.always_include_zero_in_axis,
767
+ max_items_per_plot=max_items,
768
+ max_subplots_per_file=cfg.max_subplots_per_file,
769
+ max_items_per_subplot_column=cfg.max_items_per_subplot_column,
770
+ skip_data_with_only_zeroes=cfg.skip_data_with_only_zeroes,
771
+ only_file_index=only_file_index,
772
+ color_template=color_tmpl,
773
+ category=color_category,
774
+ entity_class=color_entity_class,
775
+ )
776
+ total_file_count += count
777
+ figures.extend(figs)
778
+
779
+ elif fm_subplot_levels or (cfg.subplots_by_magnitudes and not fm_stack_levels):
780
+ if fm_stack_levels:
781
+ figs, count = build_stack_figures(
782
+ df_fm, effective_plot_name, '',
783
+ fm_stack_levels, fm_subplot_levels,
784
+ rows=plot_rows, subplots_per_row=cfg.subplots_per_row,
785
+ legend_position=cfg.legend,
786
+ xlabel=cfg.xlabel, ylabel=cfg.ylabel,
787
+ base_width_per_col=6, subplot_height=cfg.base_length,
788
+ axis_bounds=axis_bounds,
789
+ axis_tick_format=cfg.axis_tick_format,
790
+ always_include_zero_in_axis=cfg.always_include_zero_in_axis,
791
+ max_items_per_plot=max_items,
792
+ max_subplots_per_file=cfg.max_subplots_per_file,
793
+ only_file_index=only_file_index,
794
+ color_template=color_tmpl,
795
+ category=color_category,
796
+ entity_class=color_entity_class,
797
+ )
798
+ else:
799
+ figs, count = build_line_figures(
800
+ df_fm, effective_plot_name, '',
801
+ fm_subplot_levels, fm_line_levels,
802
+ rows=plot_rows, subplots_per_row=cfg.subplots_per_row,
803
+ legend_position=cfg.legend,
804
+ xlabel=cfg.xlabel, ylabel=cfg.ylabel,
805
+ base_width_per_col=6, subplot_height=cfg.base_length,
806
+ axis_bounds=axis_bounds,
807
+ axis_tick_format=cfg.axis_tick_format,
808
+ always_include_zero_in_axis=cfg.always_include_zero_in_axis,
809
+ max_items_per_plot=max_items,
810
+ max_subplots_per_file=cfg.max_subplots_per_file,
811
+ subplots_by_magnitudes=cfg.subplots_by_magnitudes,
812
+ only_file_index=only_file_index,
813
+ color_template=color_tmpl,
814
+ category=color_category,
815
+ entity_class=color_entity_class,
816
+ )
817
+ total_file_count += count
818
+ figures.extend(figs)
819
+
820
+ else:
821
+ # TIME without subplots -- split items into files
822
+ item_levels = fm_line_levels if fm_line_levels else fm_stack_levels
823
+ if not item_levels:
824
+ all_items = [None]
825
+ elif len(item_levels) == 1:
826
+ all_items = df_fm.columns.get_level_values(item_levels[0]).unique().tolist()
827
+ else:
828
+ item_level_names = [df_fm.columns.names[i] for i in item_levels]
829
+ item_df = df_fm.columns.to_frame()[item_level_names].drop_duplicates()
830
+ all_items = [tuple(row) for row in item_df.values]
831
+
832
+ if not fm_subplot_levels:
833
+ all_subs = [None]
834
+ elif len(fm_subplot_levels) == 1:
835
+ all_subs = df_fm.columns.get_level_values(fm_subplot_levels[0]).unique().tolist()
836
+ else:
837
+ sub_df = df_fm.columns.to_frame().iloc[:, fm_subplot_levels].drop_duplicates()
838
+ all_subs = [tuple(row) for row in sub_df.values]
839
+
840
+ needs_item_split = len(all_items) > max_items
841
+ file_chunks = _plan_file_splits(
842
+ all_subs, all_items, fm_subplot_levels,
843
+ max_items, cfg.max_subplots_per_file,
844
+ )
845
+ total_file_count += len(file_chunks)
846
+ for file_idx, item_chunk in enumerate(file_chunks, start=1):
847
+ if only_file_index is not None and (file_idx - 1) != only_file_index:
848
+ continue
849
+ df_chunk = df_fm.copy()
850
+
851
+ if needs_item_split:
852
+ if item_levels and item_chunk[0] is not None:
853
+ if len(item_levels) == 1:
854
+ level_name = df_fm.columns.names[item_levels[0]]
855
+ mask = df_chunk.columns.get_level_values(level_name).isin(item_chunk)
856
+ df_chunk = df_chunk.loc[:, mask]
857
+ else:
858
+ item_level_names = [df_fm.columns.names[i] for i in item_levels]
859
+ col_tuples = df_chunk.columns.to_frame()[item_level_names]
860
+ mask = col_tuples.apply(tuple, axis=1).isin(item_chunk)
861
+ df_chunk = df_chunk.loc[:, mask.values]
862
+
863
+ if df_chunk.empty:
864
+ continue
865
+
866
+ if fm_stack_levels:
867
+ chunk_figs, _count = build_stack_figures(
868
+ df_chunk, effective_plot_name, '',
869
+ fm_stack_levels, fm_subplot_levels,
870
+ rows=plot_rows, legend_position=cfg.legend,
871
+ xlabel=cfg.xlabel, ylabel=cfg.ylabel,
872
+ base_width_per_col=6, subplot_height=cfg.base_length,
873
+ axis_bounds=axis_bounds,
874
+ axis_tick_format=cfg.axis_tick_format,
875
+ always_include_zero_in_axis=cfg.always_include_zero_in_axis,
876
+ color_template=color_tmpl,
877
+ category=color_category,
878
+ entity_class=color_entity_class,
879
+ )
880
+ else:
881
+ chunk_figs, _count = build_line_figures(
882
+ df_chunk, effective_plot_name, '',
883
+ fm_subplot_levels, fm_line_levels,
884
+ rows=plot_rows, legend_position=cfg.legend,
885
+ xlabel=cfg.xlabel, ylabel=cfg.ylabel,
886
+ base_width_per_col=6, subplot_height=cfg.base_length,
887
+ axis_bounds=axis_bounds,
888
+ axis_tick_format=cfg.axis_tick_format,
889
+ always_include_zero_in_axis=cfg.always_include_zero_in_axis,
890
+ color_template=color_tmpl,
891
+ category=color_category,
892
+ entity_class=color_entity_class,
893
+ )
894
+ figures.extend(chunk_figs)
895
+
896
+ return figures, total_file_count
897
+
898
+
899
+ def plot_dict_of_dataframes(results_dict, plot_dir, plot_settings,
900
+ active_settings=['default'], plot_rows=(0, 167), delete_existing_plots=True,
901
+ plot_file_format='png', only_first_file=False,
902
+ break_times: set[str] | None = None,
903
+ color_path: Path | None = None):
904
+ """
905
+ Plot dataframes from a dictionary according to key suffixes.
906
+
907
+ Args:
908
+ results_dict: Dictionary of pandas DataFrames
909
+ plot_dir: Directory to save PNG files
910
+ plot_settings: Dict mapping result keys to plot configuration
911
+ active_settings: List of named configs to activate (default ['default'])
912
+ plot_rows: Row slice to apply for time-series plots
913
+ delete_existing_plots: If True, delete all existing plots in plot_dir (default True)
914
+ """
915
+
916
+ # Flatten new-format entries (entry-name grouping) to flat result_key mapping
917
+ plot_settings = flatten_new_format(plot_settings)
918
+
919
+ # Extract period weights once (for 'y'/'z' rules). Accept either a Series
920
+ # (period → years) for single-scenario data, or a DataFrame with 'scenario'
921
+ # as the column level for combined/comparison data.
922
+ period_weights = _extract_period_weights(results_dict)
923
+
924
+ # Empty plot dir if requested
925
+ if delete_existing_plots:
926
+ for filename in os.listdir(plot_dir):
927
+ file_path = os.path.join(plot_dir, filename)
928
+ if filename == 'config.yaml':
929
+ continue
930
+ if os.path.isfile(file_path):
931
+ os.remove(file_path)
932
+
933
+ skipped_files = 0 # track files not plotted due to only_first_file
934
+
935
+ for key in plot_settings:
936
+ if key not in results_dict:
937
+ logger.info(f"Plot key '{key}' not found in results \u2014 skipping")
938
+ continue
939
+ df_orig = results_dict[key]
940
+ if df_orig.empty:
941
+ continue
942
+
943
+ # Collect active configs for this key
944
+ chosen_settings = []
945
+ entry = plot_settings[key]
946
+ if _is_single_config(entry):
947
+ if 'default' in active_settings:
948
+ chosen_settings.append(('default', entry))
949
+ else:
950
+ for setting_name, setting in entry.items():
951
+ if setting_name in active_settings:
952
+ chosen_settings.append((setting_name, setting))
953
+
954
+ for setting_name, setting in chosen_settings:
955
+ # Parse raw dict into typed PlotConfig; warn about unknown keys
956
+ # Backward compat: map old 'axis_scale_min_max' key to 'axis_bounds'
957
+ # _entry_name is injected by flatten_new_format, not a PlotConfig field
958
+ entry_name = setting.pop('_entry_name', None)
959
+ unknown_keys = [k for k in setting if k not in PLOT_FIELD_NAMES]
960
+ if unknown_keys:
961
+ logging.warning(
962
+ f"Plot config '{key}': ignoring unknown setting(s): "
963
+ f"{', '.join(repr(k) for k in unknown_keys)}"
964
+ )
965
+ filtered = {k: v for k, v in setting.items() if k in PLOT_FIELD_NAMES}
966
+ if 'axis_scale_min_max' in filtered and 'axis_bounds' not in filtered:
967
+ filtered['axis_bounds'] = filtered.pop('axis_scale_min_max')
968
+ elif 'axis_scale_min_max' in filtered:
969
+ del filtered['axis_scale_min_max']
970
+ variant = filtered.pop('variant', None)
971
+ cfg = PlotConfig(**filtered)
972
+
973
+ # Variant letter for the value-axis unit: an explicit ``variant``
974
+ # override wins, else derive from the sub-config name so the ``a``
975
+ # (sum_periods → total, ``/a`` stripped) and ``w`` (chunks →
976
+ # weekly) variants get the adjusted unit rather than the base.
977
+ variant_letter = variant
978
+ if variant_letter is None:
979
+ variant_letter = {
980
+ 'chunks': 'w', 'sum_periods': 'a',
981
+ }.get(setting_name)
982
+
983
+ # Default the y-axis label to the output's unit (single source:
984
+ # the metadata catalog) when the user has not set one explicitly.
985
+ if cfg.ylabel is None:
986
+ cfg.ylabel = default_ylabel_for(key, variant_letter)
987
+
988
+ # Plot title: explicit plot_name > entry name > result key
989
+ plot_name = cfg.plot_name or entry_name or key
990
+
991
+ # Filename: use plot_name, append variant to avoid collisions
992
+ # between e.g. period bars (_p) and totals (_a)
993
+ if variant and not cfg.plot_name:
994
+ file_name = f"{plot_name}_{variant}"
995
+ else:
996
+ file_name = plot_name
997
+
998
+ # Use prepare_plot_data for the core logic
999
+ figures, _total_count = prepare_plot_data(
1000
+ df_orig, cfg,
1001
+ plot_name=plot_name,
1002
+ plot_rows=plot_rows,
1003
+ break_times=break_times,
1004
+ period_weights=period_weights,
1005
+ color_path=color_path,
1006
+ )
1007
+
1008
+ if only_first_file and len(figures) > 1:
1009
+ skipped_files += len(figures) - 1
1010
+ figures = figures[:1]
1011
+
1012
+ # Save each figure to disk
1013
+ for fig_idx, (fig_title, fig) in enumerate(figures, start=1):
1014
+ filepath = generate_split_filename(
1015
+ file_name, plot_dir, plot_file_format,
1016
+ file_idx=fig_idx if len(figures) > 1 else None,
1017
+ needs_split=(len(figures) > 1),
1018
+ )
1019
+ fig.savefig(filepath)
1020
+ plt.close(fig)
1021
+
1022
+ if only_first_file and skipped_files > 0:
1023
+ logger.warning(
1024
+ "'Just one file per plot' active \u2014 %d file(s) not plotted.", skipped_files
1025
+ )
1026
+
1027
+
1028
+ def compute_all_plot_plans(
1029
+ results_dict: dict,
1030
+ plot_settings: dict,
1031
+ output_dir,
1032
+ active_settings=None,
1033
+ plot_rows: tuple = (0, 167),
1034
+ break_times=None,
1035
+ strip_scenario_level: bool = True,
1036
+ color_path: Path | None = None,
1037
+ ) -> None:
1038
+ """Compute and save PlotPlans for all results.
1039
+
1040
+ Called after scenario runs / comparison builds so that the viewer
1041
+ can load pre-computed plans instead of re-running dimension rules.
1042
+ Also writes an ``_availability.json`` manifest listing all
1043
+ (result_key, sub_config) pairs that produced valid plans.
1044
+
1045
+ For per-scenario runs (``output_dir`` sits inside
1046
+ ``<project>/output_parquet/<scenario>/``) the function also folds the
1047
+ resulting subplot y-ranges into a cross-scenario axis-bounds manifest
1048
+ at ``<project>/output_parquet/_axis_bounds.json`` so the viewer
1049
+ can keep the y-axis stable when switching scenarios. Comparison /
1050
+ cross-scenario output dirs (which don't live under ``output_parquet/``)
1051
+ don't need the shared manifest and skip this step.
1052
+ """
1053
+ import json
1054
+ import tempfile
1055
+ from pathlib import Path
1056
+ from flextool.plot_outputs.plan import compute_plot_plans_for_result
1057
+ from flextool.plot_outputs.shared_manifest import ManifestAccumulator
1058
+
1059
+ # Flatten new-format entries (entry-name grouping) to flat result_key mapping
1060
+ plot_settings = flatten_new_format(plot_settings)
1061
+
1062
+ # Extract period weights once for 'y'/'z' rule support
1063
+ period_weights = _extract_period_weights(results_dict)
1064
+
1065
+ output_dir = Path(output_dir)
1066
+ plan_dir = output_dir / "plot_plans"
1067
+
1068
+ # Clean old plans so stale files from previous runs don't linger
1069
+ if plan_dir.is_dir():
1070
+ import shutil
1071
+ shutil.rmtree(plan_dir)
1072
+ plan_dir.mkdir(parents=True, exist_ok=True)
1073
+
1074
+ # Decide whether this batch should contribute to the shared axis-bounds
1075
+ # manifest. Only per-scenario output_dirs (parent named ``output_parquet``)
1076
+ # get a ManifestAccumulator — comparison dirs have their own axis logic and
1077
+ # don't participate in the cross-scenario stability concern.
1078
+ #
1079
+ # The scenario name is the on-disk folder name (``output_dir.name``),
1080
+ # which matches the keys the GUI's ExecutedScenarioManager uses
1081
+ # (the bare scenario name when this source owns it, or
1082
+ # ``<scenario>_<source_number>`` when another source does).
1083
+ manifest_accumulator = None
1084
+ manifest_scenario_name: str | None = None
1085
+ if output_dir.parent.name == "output_parquet":
1086
+ # project_path is two levels up: <project>/output_parquet/<scenario>
1087
+ project_path = output_dir.parent.parent
1088
+ manifest_scenario_name = output_dir.name
1089
+ try:
1090
+ manifest_accumulator = ManifestAccumulator(project_path)
1091
+ except Exception as exc:
1092
+ logger.warning(
1093
+ "Failed to initialise shared axis-bounds accumulator: %s", exc,
1094
+ )
1095
+ manifest_accumulator = None
1096
+ manifest_scenario_name = None
1097
+
1098
+ available: list[list[str]] = []
1099
+
1100
+ for key, df in results_dict.items():
1101
+ if key not in plot_settings or df.empty:
1102
+ continue
1103
+ # Strip the scenario column MultiIndex level for per-scenario plans
1104
+ # (per-scenario parquets have it from write_outputs).
1105
+ # Comparison plans keep the scenario level — the config expects it.
1106
+ if strip_scenario_level and isinstance(df.columns, pd.MultiIndex) and 'scenario' in df.columns.names:
1107
+ df = df.droplevel('scenario', axis=1)
1108
+ try:
1109
+ pairs = compute_plot_plans_for_result(
1110
+ df, key, plot_settings, plan_dir,
1111
+ plot_rows, break_times, active_settings,
1112
+ period_weights=period_weights,
1113
+ manifest_accumulator=manifest_accumulator,
1114
+ manifest_scenario_name=manifest_scenario_name,
1115
+ color_path=color_path,
1116
+ )
1117
+ available.extend([k, s] for k, s in pairs)
1118
+ except Exception as exc:
1119
+ logger.warning("Failed to compute plot plan for '%s': %s", key, exc)
1120
+
1121
+ # Write availability manifest atomically (write to temp, then rename)
1122
+ plan_dir.mkdir(parents=True, exist_ok=True)
1123
+ avail_path = plan_dir / "_availability.json"
1124
+ try:
1125
+ fd, tmp_path = tempfile.mkstemp(
1126
+ dir=str(plan_dir), prefix="_avail_", suffix=".tmp",
1127
+ )
1128
+ try:
1129
+ with open(fd, "w") as f:
1130
+ json.dump({"available": available}, f)
1131
+ Path(tmp_path).replace(avail_path)
1132
+ except BaseException:
1133
+ Path(tmp_path).unlink(missing_ok=True)
1134
+ raise
1135
+ except Exception as exc:
1136
+ logger.warning("Failed to write availability manifest: %s", exc)
1137
+
1138
+ # Write the cross-scenario axis-bounds manifest (only populated for
1139
+ # per-scenario runs — see manifest_accumulator setup above).
1140
+ if manifest_accumulator is not None:
1141
+ manifest_accumulator.write()