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,1510 @@
1
+ import pandas as pd
2
+ import matplotlib.pyplot as plt
3
+ from matplotlib.figure import Figure
4
+ from matplotlib.ticker import MaxNLocator, FixedLocator, FixedFormatter
5
+ from flextool.plot_outputs.format_helpers import _get_value_formatter, format_value_label
6
+ from flextool.plot_outputs.legend_helpers import (
7
+ estimate_legend_width, estimate_legend_height,
8
+ _format_legend_labels, _should_show_legend,
9
+ build_shared_color_map,
10
+ )
11
+ from flextool.plot_outputs.color_template import order_labels_by_template
12
+ from flextool.plot_outputs.axis_helpers import (
13
+ _subplot_axis_bounds, _apply_subplot_label, _estimate_value_nbins,
14
+ _ylabel_axes_x,
15
+ )
16
+ from flextool.plot_outputs.subplot_helpers import (
17
+ BarLayoutParams, _calculate_grid_layout, _get_unique_levels, _extract_subplot_data,
18
+ _sort_subs,
19
+ )
20
+ from flextool.plot_outputs.plot_bars_detail import (
21
+ _plot_grouped_bars, _plot_stacked_bars, _plot_simple_bars,
22
+ )
23
+
24
+ # ── Layout constants (inches) ──
25
+ CHAR_WIDTH = 0.081 # Approximate width per character at font-size 9
26
+ LEFT_PAD = 0.25 # Left edge padding
27
+ RIGHT_PAD = 0.35 # Right margin for value-axis tick labels
28
+ BAR_HEIGHT = 0.24 # Minimum row slot height (font-driven baseline)
29
+ # Bar thickness is fixed per row regardless of how many bars share the row.
30
+ # REFERENCE_BARS_PER_ROW + (n-1) gaps fit exactly into BAR_HEIGHT, so a row
31
+ # of REFERENCE_BARS_PER_ROW bars uses the baseline slot. Fewer bars leave
32
+ # whitespace; more bars grow the slot.
33
+ REFERENCE_BARS_PER_ROW = 4
34
+ BAR_GAP_FRACTION = 0.10 # gap between adjacent bars = 10% of bar thickness
35
+ SOLO_BAR_THICKNESS_MULT = 2.0 # one-bar-per-row case (no grouping → no legend needed for groups)
36
+ _REF_DENOM = REFERENCE_BARS_PER_ROW + (REFERENCE_BARS_PER_ROW - 1) * BAR_GAP_FRACTION
37
+ REFERENCE_BAR_THICKNESS = BAR_HEIGHT / _REF_DENOM # ≈ 0.0558 in
38
+ SOLO_BAR_THICKNESS = REFERENCE_BAR_THICKNESS * SOLO_BAR_THICKNESS_MULT # ≈ 0.1116 in
39
+ # When value labels are drawn on GROUPED bars (several bars — and several
40
+ # labels — share one slot), the thin reference bar thickness leaves the
41
+ # numeric labels too close together (center-to-center pitch ~0.44x the label
42
+ # font line height, so labels overlap). Thicken the grouped bars — and grow
43
+ # their slot to match — by this factor so each bar's label gains a font line
44
+ # of pitch. Single-bar-per-label plots do NOT need this: there is one label
45
+ # per slot, so the slot height alone separates the labels (see
46
+ # VALUE_LABEL_SLOT_FLOOR); fattening the lone bar would just bloat it.
47
+ VALUE_LABEL_BAR_THICKNESS_MULT = 2.5
48
+ # Minimum slot height when value labels are drawn, so a single bar's label
49
+ # (placed at the bar tip) just clears its neighbours' labels. Sits a hair above
50
+ # one value-label font line (~0.167" at 10pt) — only ~0.01" more than the
51
+ # unlabelled horizontal floor (0.70*BAR_HEIGHT=0.168"), so labelled bars space
52
+ # almost like unlabelled ones. The bar itself stays thin; only the slot grows.
53
+ VALUE_LABEL_SLOT_FLOOR = 0.18
54
+ # Per-category slot floor as a fraction of BAR_HEIGHT, by orientation. The
55
+ # 0.24" baseline reserves room for a *horizontal* tick label beside each bar,
56
+ # so for horizontal bars most of it is needed. Vertical bars rotate their tick
57
+ # labels 90° and need far less lateral room, so their floor drops close to the
58
+ # bar thickness itself — max(floor, needed) then falls back to the bar
59
+ # thickness and pulls the bars tightly together (gap shrinks ~70%). Horizontal
60
+ # bars trim ~30% of the baseline, the most their labels can spare.
61
+ VERTICAL_SLOT_FLOOR_MULT = 0.625 # vertical bars: slot ≈ bar thickness + thin gap
62
+ HORIZONTAL_SLOT_FLOOR_MULT = 0.70 # horizontal bars: ~30% tighter than baseline
63
+ SUBPLOT_VPAD = 0.3 # Space above axes for subplot title
64
+ INTER_COL_GAP = 0.4 # Horizontal gap between subplot columns
65
+ INTER_ROW_GAP = 0.6 # Vertical gap between subplot rows
66
+ VALUE_LABEL_MARGIN = 0.2 # Extra right margin when value labels are shown
67
+ YLABEL_WIDTH = 0.4 # Space reserved for y-axis label text
68
+ XLABEL_HEIGHT = 0.2 # Space reserved for x-axis label text
69
+ LEGEND_GAP = 0.15 # Gap between drawing area and legend box
70
+ TITLE_PAD = 0.3 # Top margin for subplot title above axes
71
+ BOTTOM_PAD = 0.35 # Bottom margin for x-axis ticks/labels below axes
72
+
73
+ # Category (bar) tick labels are middle-ellipsised to at most this many
74
+ # characters PER index/group component. A single pathological name (e.g.
75
+ # "EGNGSC_Shoubra_El-Kheima_Steam_Turbines") would otherwise set the left
76
+ # margin for every subplot in the grid, squeezing all the bars. Middle
77
+ # truncation keeps both the head and the tail so the name stays
78
+ # recognisable; the GUI shows the full text on hover.
79
+ BAR_LABEL_MAX_CHARS = 24
80
+ _ELLIPSIS = "…"
81
+
82
+
83
+ def _reattach_constant_levels(df_sub, sub, sub_names: list[str], all_consumed: bool):
84
+ """Re-add subplot levels that ``_extract_subplot_data`` dropped.
85
+
86
+ The live render path drops the subplot level(s); the plan path keeps
87
+ them. To color a simple bar by a subplot-level entity uniformly across
88
+ both paths, restore the dropped level(s) as constant column levels
89
+ (value = this subplot's ``sub``). Level position doesn't matter — the
90
+ color lookup and bar machinery key off level *names*.
91
+
92
+ When the subplot consumed *all* column levels (``all_consumed``),
93
+ extraction left a spurious value-as-label column, so rebuild the column
94
+ index as the constant tuple; otherwise prepend the level(s) to the real
95
+ remaining ones.
96
+ """
97
+ sub_vals = list(sub) if isinstance(sub, tuple) else [sub]
98
+ if len(sub_vals) != len(sub_names):
99
+ return df_sub
100
+ if all_consumed:
101
+ df_sub = df_sub.copy()
102
+ df_sub.columns = pd.MultiIndex.from_tuples(
103
+ [tuple(sub_vals)] * df_sub.shape[1], names=sub_names,
104
+ )
105
+ return df_sub
106
+ for name, val in zip(reversed(sub_names), reversed(sub_vals)):
107
+ df_sub = pd.concat(
108
+ {val: df_sub}, axis=1, names=[name] + list(df_sub.columns.names),
109
+ )
110
+ return df_sub
111
+
112
+
113
+ def _truncate_middle(text: str, cap: int = BAR_LABEL_MAX_CHARS) -> str:
114
+ """Shorten *text* to *cap* chars keeping head and tail, ellipsis in between."""
115
+ if len(text) <= cap:
116
+ return text
117
+ if cap <= 1:
118
+ return text[:cap]
119
+ keep = cap - 1 # one char spent on the ellipsis
120
+ head = (keep + 1) // 2
121
+ tail = keep - head
122
+ return text[:head] + _ELLIPSIS + (text[-tail:] if tail else "")
123
+
124
+
125
+ def _bar_label_components(idx_val, group=None) -> list[str]:
126
+ """Flatten an expand group (outer) + a bar's index value (inner) into parts.
127
+
128
+ The expand-group level(s) come FIRST, then the bar index level(s), so the
129
+ label reads outer→inner (e.g. ``node | unit``) — matching the old
130
+ "groups" layout where the group label sat to the left of the bar label.
131
+ Both group and idx_val may be tuples (2+ dimensions), each contributing
132
+ its levels in order.
133
+ """
134
+ parts: list = []
135
+ if group is not None:
136
+ parts += list(group) if isinstance(group, tuple) else [group]
137
+ parts += list(idx_val) if isinstance(idx_val, tuple) else [idx_val]
138
+ return [str(p) for p in parts]
139
+
140
+
141
+ def _format_bar_label(idx_val, group=None, *, truncate: bool = True) -> str:
142
+ """Build a bar's display label as ``group | … | bar`` (expand group first).
143
+
144
+ With ``truncate`` each component is middle-ellipsised to
145
+ ``BAR_LABEL_MAX_CHARS``. ``truncate=False`` yields the full label used for
146
+ the GUI hover tooltip.
147
+ """
148
+ parts = _bar_label_components(idx_val, group)
149
+ if truncate:
150
+ parts = [_truncate_middle(p) for p in parts]
151
+ return " | ".join(parts)
152
+
153
+
154
+ def _bar_label_width_inches(
155
+ effective_plots: list[tuple[str | None, "pd.DataFrame"]],
156
+ expand_axis_levels: list[int],
157
+ expand_axis_level_names: list[str],
158
+ ) -> float:
159
+ """Inches to reserve for the category tick-label column of *effective_plots*.
160
+
161
+ Sized to the longest label that is ACTUALLY drawn — the widest real
162
+ "group | … | bar" combination over the bars that have data. It is NOT an
163
+ upper bound like (longest group anywhere) + (longest bar anywhere): those
164
+ two can live on different rows, so summing them over-reserves and leaves
165
+ empty space even on the widest real row. Each component is middle-
166
+ truncated, exactly as drawn.
167
+
168
+ Passed every subplot across all files so the margin is shared (the plot
169
+ area keeps the same horizontal position when paging through files).
170
+ """
171
+ def _group_value(columns, col):
172
+ """The expand-level value(s) of *col* — what gets folded into labels."""
173
+ if not (expand_axis_levels and expand_axis_level_names):
174
+ return None
175
+ if not isinstance(columns, pd.MultiIndex):
176
+ return col
177
+ try:
178
+ pos = [columns.names.index(n) for n in expand_axis_level_names]
179
+ except ValueError:
180
+ return col
181
+ vals = tuple(col[p] for p in pos)
182
+ return vals[0] if len(vals) == 1 else vals
183
+
184
+ max_chars = 0
185
+ for _, df_sub in effective_plots:
186
+ clean = df_sub.dropna(how='all')
187
+ if clean.empty:
188
+ continue
189
+ if not (expand_axis_levels and expand_axis_level_names):
190
+ for iv in clean.index:
191
+ max_chars = max(max_chars, len(_format_bar_label(iv)))
192
+ continue
193
+ # Group is constant per column; the widest label in a column is its
194
+ # group joined with the longest bar that actually has data there.
195
+ unit_len = {iv: len(_format_bar_label(iv)) for iv in clean.index}
196
+ for col in clean.columns:
197
+ series = clean[col]
198
+ if isinstance(series, pd.DataFrame): # duplicate column labels
199
+ present = clean.index[series.notna().any(axis=1)]
200
+ else:
201
+ present = clean.index[series.notna()]
202
+ if len(present) == 0:
203
+ continue
204
+ widest = max(present, key=lambda iv: unit_len[iv])
205
+ gval = _group_value(clean.columns, col)
206
+ max_chars = max(max_chars, len(_format_bar_label(widest, gval)))
207
+ return max_chars * CHAR_WIDTH
208
+
209
+
210
+ def _compute_bar_layout(
211
+ effective_plots: list[tuple[str | None, pd.DataFrame]],
212
+ df: pd.DataFrame,
213
+ expand_axis_levels: list[int],
214
+ expand_axis_level_names: list[str],
215
+ stack_levels: list[int],
216
+ stack_level_names: list[str],
217
+ grouped_bar_levels: list[int],
218
+ grouped_bar_level_names: list[str],
219
+ legend_position: str,
220
+ subplots_per_row: int,
221
+ base_bar_length: float,
222
+ ) -> BarLayoutParams:
223
+ """Compute layout parameters that must be consistent across file batches.
224
+
225
+ Examines ALL effective_plots (every subplot across every file) so that
226
+ each file reserves the SAME margins — the plot area then stays at the
227
+ same horizontal location when paging through files. The category-label
228
+ width is the tight true-max across those subplots (see
229
+ ``_bar_label_width_inches``), with each label component capped, so the
230
+ shared margin is as narrow as a shared margin can be.
231
+ """
232
+ n_subs = len(effective_plots)
233
+ n_rows, n_cols = _calculate_grid_layout(n_subs, subplots_per_row)
234
+
235
+ # ── bar-label width (widest DRAWN label across ALL subplots) ──
236
+ # The drawn tick label folds the expand-group level into each bar label
237
+ # ("group | … | bar") and is middle-truncated per component. Size the
238
+ # column to the ACTUAL widest drawn label: per subplot, combine that
239
+ # subplot's longest bar label with the longest group label *present in
240
+ # it*, then take the global max. (Summing the global-max bar with the
241
+ # global-max group would over-reserve when, e.g., the long unit lives in
242
+ # one node's subplot and the long node name in another's.)
243
+ bar_label_width = _bar_label_width_inches(
244
+ effective_plots, expand_axis_levels, expand_axis_level_names
245
+ )
246
+ # The expand-axis group is folded into the bar tick label, so there is no
247
+ # separate group-label column; the total reserved width is the bar label.
248
+ total_label_width = bar_label_width
249
+
250
+ # ── legend width and height (max across ALL subplots) ──
251
+ legend_width = 0.0
252
+ max_legend_entries = 0
253
+ legend_has_title = False
254
+ if stack_levels or grouped_bar_levels:
255
+ if legend_position == 'shared':
256
+ # Union of all labels across all subplots
257
+ all_items_union: list = []
258
+ leg_title = ''
259
+ for _, df_sub in effective_plots:
260
+ if grouped_bar_levels:
261
+ if len(grouped_bar_level_names) == 1:
262
+ items = df_sub.columns.get_level_values(
263
+ grouped_bar_level_names[0]).unique().astype(str).tolist()
264
+ else:
265
+ item_df = df_sub.columns.to_frame()[grouped_bar_level_names].drop_duplicates()
266
+ items = [tuple(str(v) for v in row) for row in item_df.values]
267
+ if isinstance(df_sub.columns, pd.MultiIndex):
268
+ leg_title = ' | '.join(str(n) for n in grouped_bar_level_names)
269
+ else:
270
+ leg_title = str(df_sub.columns.name) if df_sub.columns.name else 'group'
271
+ else:
272
+ if len(stack_level_names) == 1:
273
+ if isinstance(df_sub.columns, pd.MultiIndex):
274
+ items = df_sub.columns.get_level_values(
275
+ stack_level_names[0]).unique().astype(str).tolist()
276
+ else:
277
+ items = [str(c) for c in df_sub.columns]
278
+ else:
279
+ stack_df = df_sub.columns.to_frame()[stack_level_names].drop_duplicates()
280
+ items = [tuple(str(v) for v in row) for row in stack_df.values]
281
+ if isinstance(df_sub.columns, pd.MultiIndex):
282
+ leg_title = ' | '.join(str(n) for n in stack_level_names)
283
+ else:
284
+ leg_title = str(df_sub.columns.name) if df_sub.columns.name else 'stack'
285
+ for item in items:
286
+ if item not in all_items_union:
287
+ all_items_union.append(item)
288
+ legend_labels = _format_legend_labels(all_items_union)
289
+ legend_width = estimate_legend_width(legend_labels, leg_title)
290
+ max_legend_entries = len(all_items_union)
291
+ legend_has_title = True
292
+ else:
293
+ for _, df_sub in effective_plots:
294
+ if grouped_bar_levels:
295
+ if len(grouped_bar_level_names) == 1:
296
+ items = df_sub.columns.get_level_values(
297
+ grouped_bar_level_names[0]).unique().astype(str).tolist()
298
+ else:
299
+ item_df = df_sub.columns.to_frame()[grouped_bar_level_names].drop_duplicates()
300
+ items = [tuple(str(v) for v in row) for row in item_df.values]
301
+ if isinstance(df_sub.columns, pd.MultiIndex):
302
+ leg_title = ' | '.join(str(n) for n in grouped_bar_level_names)
303
+ else:
304
+ leg_title = str(df_sub.columns.name) if df_sub.columns.name else 'group'
305
+ else:
306
+ if len(stack_level_names) == 1:
307
+ if isinstance(df_sub.columns, pd.MultiIndex):
308
+ all_stacks = df_sub.columns.get_level_values(
309
+ stack_level_names[0]).unique().tolist()
310
+ # Only count stacks with non-zero values (matching actual legend)
311
+ items = []
312
+ for s in all_stacks:
313
+ try:
314
+ vals = df_sub.xs(s, level=stack_level_names[0], axis=1)
315
+ except (KeyError, TypeError):
316
+ continue
317
+ if (vals.abs() > 1e-6).any().any():
318
+ items.append(str(s))
319
+ if not items:
320
+ items = [str(s) for s in all_stacks]
321
+ else:
322
+ # Single-level columns after subplot extraction
323
+ items = [str(c) for c in df_sub.columns if (df_sub[c].abs() > 1e-6).any()]
324
+ if not items:
325
+ items = [str(c) for c in df_sub.columns]
326
+ else:
327
+ stack_df = df_sub.columns.to_frame()[stack_level_names].drop_duplicates()
328
+ items = [tuple(str(v) for v in row) for row in stack_df.values]
329
+ if isinstance(df_sub.columns, pd.MultiIndex):
330
+ leg_title = ' | '.join(str(n) for n in stack_level_names)
331
+ else:
332
+ leg_title = str(df_sub.columns.name) if df_sub.columns.name else 'stack'
333
+ legend_labels = _format_legend_labels(items)
334
+ w = estimate_legend_width(legend_labels, leg_title)
335
+ legend_width = max(legend_width, w)
336
+ max_legend_entries = max(max_legend_entries, len(items))
337
+ legend_has_title = True # all legend paths set a title
338
+
339
+ legend_height = estimate_legend_height(max_legend_entries, legend_has_title) if max_legend_entries > 0 else 0.0
340
+
341
+ # ── value-axis tick label width (for vertical bars) ──
342
+ # Estimate from the extreme data values across all subplots
343
+ max_val_chars = 1
344
+ for _, df_sub in effective_plots:
345
+ numeric = df_sub.select_dtypes(include='number')
346
+ if numeric.empty:
347
+ continue
348
+ extremes = [numeric.min().min(), numeric.max().max()]
349
+ for val in extremes:
350
+ if pd.notna(val):
351
+ formatted = f'{val:,.6g}'
352
+ max_val_chars = max(max_val_chars, len(formatted))
353
+ value_axis_width = max(0.5, max_val_chars * CHAR_WIDTH + 0.15)
354
+
355
+ return BarLayoutParams(
356
+ bar_label_width=bar_label_width,
357
+ total_label_width=total_label_width,
358
+ legend_width=legend_width,
359
+ legend_height=legend_height,
360
+ base_bar_length=base_bar_length,
361
+ value_axis_width=value_axis_width,
362
+ )
363
+
364
+
365
+ def _build_bar_figure(
366
+ effective_plots, df, key_name, plot_dir,
367
+ stack_levels, stack_level_names,
368
+ expand_axis_levels, expand_axis_level_names,
369
+ sub_levels,
370
+ grouped_bar_levels, grouped_bar_level_names,
371
+ legend_position, subplots_per_row,
372
+ xlabel, ylabel, bar_orientation, base_bar_length,
373
+ value_fmt, axis_bounds, axis_tick_format,
374
+ always_include_zero_in_axis,
375
+ layout: BarLayoutParams | None = None,
376
+ shared_color_map: dict[str, tuple] | None = None,
377
+ skip_data_with_only_zeroes: bool = False,
378
+ color_bar_level: str | None = None,
379
+ ) -> plt.Figure | None:
380
+ """Build a bar-chart Figure and return it (without saving or closing)."""
381
+ # Calculate subplot grid
382
+ n_subs = len(effective_plots)
383
+ n_rows, n_cols = _calculate_grid_layout(n_subs, subplots_per_row)
384
+
385
+ # When value labels are on, thicken GROUPED bars (and grow their slot to
386
+ # match) so each bar's label gains separation — only grouped bars pack
387
+ # several labels into one slot. Single bars per label keep the thin
388
+ # thickness and get their label room from VALUE_LABEL_SLOT_FLOOR instead
389
+ # (fattening a lone bar would just bloat it). thickness_mult is captured by
390
+ # the closures below and passed to the draw functions, so the estimation
391
+ # and render passes stay consistent. value_fmt is in scope here (it is also
392
+ # used to draw the labels further down).
393
+ thickness_mult = VALUE_LABEL_BAR_THICKNESS_MULT if value_fmt else 1.0
394
+
395
+ # Per-position bar height: when grouped bars exceed 3, scale up the slot
396
+ # so each individual bar stays at least 1/3 of font height (~10pt).
397
+ # Slot height for a label row containing n bars at the FIXED bar
398
+ # thickness (REFERENCE_BAR_THICKNESS for n>1, SOLO_BAR_THICKNESS for
399
+ # n==1). Slot stays at the BAR_HEIGHT baseline whenever the bars +
400
+ # gaps fit; grows once they don't.
401
+ slot_floor = BAR_HEIGHT * (
402
+ VERTICAL_SLOT_FLOOR_MULT if bar_orientation == 'vertical'
403
+ else HORIZONTAL_SLOT_FLOOR_MULT
404
+ )
405
+ # Value labels sit at the bar tip; with one bar per label they are
406
+ # separated from their neighbours by the slot height, not by bar thickness.
407
+ # Raise the floor to fit one label line so the labels clear each other,
408
+ # while the bar stays thin (see _slot_height_for_n_grouped's n==1 branch
409
+ # and _plot_simple_bars). Grouped bars (many labels per slot) instead rely
410
+ # on thickness_mult, applied to their bar thickness below.
411
+ if value_fmt:
412
+ slot_floor = max(slot_floor, VALUE_LABEL_SLOT_FLOOR)
413
+
414
+ def _slot_height_for_n_grouped(n: int) -> float:
415
+ """Bar slot height for n grouped bars at one label."""
416
+ if n <= 0:
417
+ return slot_floor
418
+ if n == 1:
419
+ # One bar per label: keep the bar thin even when value labels are
420
+ # on. Their room comes from the (value-label-aware) slot_floor, not
421
+ # from fattening the bar.
422
+ return max(slot_floor, SOLO_BAR_THICKNESS)
423
+ bar_t = REFERENCE_BAR_THICKNESS * thickness_mult
424
+ needed = n * bar_t + (n - 1) * BAR_GAP_FRACTION * bar_t
425
+ return max(slot_floor, needed)
426
+
427
+ def _count_grouped(df_check: pd.DataFrame) -> int:
428
+ """Count grouped bar members in a DataFrame."""
429
+ if not grouped_bar_levels:
430
+ return 1
431
+ if isinstance(df_check.columns, pd.MultiIndex) and grouped_bar_level_names:
432
+ if len(grouped_bar_level_names) == 1:
433
+ return len(df_check.columns.get_level_values(
434
+ grouped_bar_level_names[0]).unique())
435
+ gf = df_check.columns.to_frame()[grouped_bar_level_names].drop_duplicates()
436
+ return len(gf)
437
+ elif not isinstance(df_check.columns, pd.MultiIndex) and grouped_bar_levels:
438
+ return len(df_check.columns.unique())
439
+ return 1
440
+
441
+ # Add extra bottom space when xlabel is present
442
+ bottom_pad = BOTTOM_PAD + (XLABEL_HEIGHT if xlabel else 0)
443
+ left_edge_pad = LEFT_PAD + (YLABEL_WIDTH if ylabel else 0)
444
+
445
+ # ── Height estimation pass ────────────────────────────────────
446
+ # Compute accurate bars_only_h per subplot (with pruning) so the
447
+ # figure is sized correctly. Pruning is cheap (column/row masks)
448
+ # and will be re-done in the render pass — no data is stored.
449
+ def _estimate_subplot_height(df_sub_raw: pd.DataFrame) -> float:
450
+ """Return bars_only_h for one subplot, applying pruning."""
451
+ df_s = df_sub_raw
452
+ if skip_data_with_only_zeroes:
453
+ df_s = df_s.loc[:, (df_s.abs() > 1e-6).any(axis=0)]
454
+ df_s = df_s.loc[(df_s.abs() > 1e-6).any(axis=1)]
455
+ if df_s.empty:
456
+ return BAR_HEIGHT # minimum
457
+
458
+ def _sum_row_heights(df_data: pd.DataFrame, n_global: int) -> float:
459
+ """Sum per-row slot heights. Must match the render pass exactly.
460
+
461
+ * Simple bars (no ``grouped_bar_levels``): every kept (non-empty)
462
+ row gets ``_slot_height_for_n_grouped(1)``; fully-empty rows
463
+ (under pruning) contribute nothing (they are not drawn).
464
+ * Grouped bars: every non-empty row reserves a slot for the
465
+ GLOBAL grouped count ``n_global`` (the same count
466
+ ``_plot_grouped_bars`` uses to position bars), so the slot is
467
+ consistent with what is drawn. Fully-empty rows fall back to
468
+ ``BAR_HEIGHT`` only if they are counted as rows; under pruning
469
+ they are removed and contribute nothing.
470
+ """
471
+ if df_data.empty:
472
+ return 0.0
473
+
474
+ # Boolean mask: True where |v| > 1e-6 (row has any non-zero).
475
+ row_has_data = (df_data.abs() > 1e-6).any(axis=1)
476
+
477
+ # --- Case A: simple bars ---------------------------------------
478
+ if not grouped_bar_levels:
479
+ if skip_data_with_only_zeroes:
480
+ n_non_zero_rows = int(row_has_data.sum())
481
+ else:
482
+ n_non_zero_rows = len(df_data.index)
483
+ return n_non_zero_rows * _slot_height_for_n_grouped(1)
484
+
485
+ # --- Case B: grouped bars --------------------------------------
486
+ # Every non-empty row reserves the GLOBAL-count slot; this is the
487
+ # render pass's behaviour (_row_height returns the global slot for
488
+ # any row with at least one non-zero bar).
489
+ slot_global = _slot_height_for_n_grouped(n_global)
490
+ if skip_data_with_only_zeroes:
491
+ n_non_empty = int(row_has_data.sum())
492
+ return n_non_empty * slot_global
493
+ return len(df_data.index) * slot_global
494
+
495
+ # GLOBAL grouped count for this subplot — the same count
496
+ # _plot_grouped_bars uses (len(grouped_bars) over the full subplot
497
+ # columns). Every non-empty row reserves a slot for this count, so the
498
+ # estimation total matches the render pass's per-row slot sum.
499
+ n_global = _count_grouped(df_s) if grouped_bar_levels else 1
500
+
501
+ if not expand_axis_levels:
502
+ return max(_sum_row_heights(df_s, n_global), BAR_HEIGHT)
503
+
504
+ # Per expand group: extract slice, sum per-row heights
505
+ total_h = 0.0
506
+ if len(expand_axis_level_names) == 1:
507
+ groups_est = df_s.columns.get_level_values(expand_axis_level_names[0]).unique()
508
+ elif isinstance(df_s.columns, pd.MultiIndex):
509
+ gf = df_s.columns.to_frame()[expand_axis_level_names].drop_duplicates()
510
+ groups_est = [tuple(r) for r in gf.values]
511
+ else:
512
+ groups_est = [df_s.columns[0]] if len(df_s.columns) else []
513
+
514
+ for grp in groups_est:
515
+ try:
516
+ if not isinstance(df_s.columns, pd.MultiIndex):
517
+ df_g = df_s[[grp]] if grp in df_s.columns else None
518
+ elif len(expand_axis_level_names) == 1:
519
+ df_g = df_s.xs(grp, level=expand_axis_level_names[0], axis=1)
520
+ else:
521
+ df_g = df_s.xs(grp, level=expand_axis_level_names, axis=1)
522
+ except KeyError:
523
+ continue
524
+ if df_g is None:
525
+ continue
526
+ if isinstance(df_g, pd.Series):
527
+ df_g = df_g.to_frame()
528
+ total_h += _sum_row_heights(df_g, n_global)
529
+ return max(total_h, BAR_HEIGHT)
530
+
531
+ subplot_sizes: list[float] = [
532
+ _estimate_subplot_height(df_sub_raw) + SUBPLOT_VPAD
533
+ for _, df_sub_raw in effective_plots
534
+ ]
535
+
536
+ # Create figure and axes using layout-derived margins
537
+ if n_subs == 1:
538
+ # Single plot — deferred until bar count is known (inside render loop)
539
+ fig = None
540
+ axes = [None]
541
+ # Precompute consistent width for single-subplot figures
542
+ _single_width = layout.base_bar_length + layout.total_label_width + left_edge_pad + RIGHT_PAD
543
+ if layout.legend_width > 0:
544
+ _single_width += layout.legend_width + LEGEND_GAP
545
+ else:
546
+ if bar_orientation == 'horizontal':
547
+ # --- Horizontal bars: height varies per subplot, width uniform ---
548
+ # Cell width excludes RIGHT_PAD; it is added once at the figure edge
549
+ cell_width = layout.base_bar_length + layout.total_label_width
550
+ # Per-cell legend space only for 'all' with multiple columns
551
+ if layout.legend_width > 0 and legend_position == 'all' and n_cols > 1:
552
+ cell_width += layout.legend_width + LEGEND_GAP
553
+
554
+ # Organize subplots into rows for top-alignment
555
+ row_data: list[list[tuple[int, float]]] = [[] for _ in range(n_rows)]
556
+ for i in range(n_subs):
557
+ r = i // n_cols
558
+ row_data[r].append((i, subplot_sizes[i]))
559
+
560
+ # Row height = tallest subplot in that row
561
+ row_heights: list[float] = [
562
+ max(h for _, h in row) if row else 0.0 for row in row_data
563
+ ]
564
+
565
+ content_height = sum(row_heights) + INTER_ROW_GAP * max(0, n_rows - 1)
566
+ total_height = content_height + TITLE_PAD + bottom_pad
567
+ # Add legend height excess
568
+ if layout and layout.legend_height > 0:
569
+ min_axes_h = min(subplot_sizes) # smallest subplot
570
+ legend_excess = max(0, layout.legend_height - min_axes_h + SUBPLOT_VPAD)
571
+ total_height += legend_excess
572
+ total_width = cell_width * n_cols + INTER_COL_GAP * max(0, n_cols - 1) + left_edge_pad + RIGHT_PAD
573
+ # For 'right' legend (or single column), add legend space once
574
+ if layout.legend_width > 0 and not (legend_position == 'all' and n_cols > 1):
575
+ total_width += layout.legend_width + LEGEND_GAP
576
+
577
+ fig = Figure(figsize=(total_width, total_height))
578
+
579
+ axes = [None] * n_subs
580
+ # Start below TITLE_PAD, end above BOTTOM_PAD
581
+ y_cursor = total_height - TITLE_PAD
582
+ for r, row in enumerate(row_data):
583
+ row_top = y_cursor
584
+ for sub_idx, sub_h in row:
585
+ c = sub_idx % n_cols
586
+ x_left = (c * (cell_width + INTER_COL_GAP) + layout.total_label_width + left_edge_pad) / total_width
587
+ ax_width = layout.base_bar_length / total_width
588
+ # Axes height = bars only; SUBPLOT_VPAD stays above for title/spacing
589
+ bars_h = sub_h - SUBPLOT_VPAD
590
+ y_bottom = (row_top - sub_h) / total_height
591
+ ax_height = bars_h / total_height
592
+ axes[sub_idx] = fig.add_axes([x_left, y_bottom, ax_width, ax_height])
593
+ y_cursor -= row_heights[r] + INTER_ROW_GAP
594
+
595
+ else: # vertical
596
+ # --- Vertical bars: width varies per subplot, height uniform ---
597
+ # For vertical: x-axis = bar labels (rotated), y-axis = values
598
+ # Each subplot cell: value_axis_labels + bars + right_pad
599
+ vert_subplot_widths = [
600
+ layout.value_axis_width + (ss - SUBPLOT_VPAD) + RIGHT_PAD
601
+ for ss in subplot_sizes
602
+ ]
603
+ cell_height = layout.base_bar_length + layout.total_label_width + SUBPLOT_VPAD
604
+
605
+ row_stacks: list[list[tuple[int, float]]] = [[] for _ in range(n_rows)]
606
+ for i in range(n_subs):
607
+ r = i // n_cols
608
+ row_stacks[r].append((i, vert_subplot_widths[i]))
609
+
610
+ row_total_widths: list[float] = []
611
+ for stack in row_stacks:
612
+ if stack:
613
+ w = sum(s for _, s in stack) + INTER_COL_GAP * (len(stack) - 1)
614
+ if layout.legend_width > 0 and legend_position == 'all' and len(stack) > 1:
615
+ w += (layout.legend_width + LEGEND_GAP) * len(stack)
616
+ elif layout.legend_width > 0:
617
+ w += layout.legend_width + LEGEND_GAP
618
+ else:
619
+ w = 0.0
620
+ row_total_widths.append(w)
621
+
622
+ total_width = max(row_total_widths) if row_total_widths else 1.0
623
+ total_width += left_edge_pad
624
+ content_height = cell_height * n_rows + INTER_ROW_GAP * max(0, n_rows - 1)
625
+ total_height = content_height + TITLE_PAD + bottom_pad
626
+ # Add legend height excess
627
+ if layout and layout.legend_height > 0:
628
+ axes_h = layout.base_bar_length
629
+ legend_excess = max(0, layout.legend_height - axes_h)
630
+ total_height += legend_excess
631
+
632
+ fig = Figure(figsize=(total_width, total_height))
633
+
634
+ axes = [None] * n_subs
635
+ for r, stack in enumerate(row_stacks):
636
+ y_top = total_height - TITLE_PAD - r * (cell_height + INTER_ROW_GAP)
637
+ y_bottom = (y_top - cell_height + layout.total_label_width) / total_height
638
+ ax_height = layout.base_bar_length / total_height
639
+
640
+ x_cursor = left_edge_pad
641
+ for sub_idx, sub_w in stack:
642
+ bars_w = sub_w - layout.value_axis_width - RIGHT_PAD
643
+ x_left = (x_cursor + layout.value_axis_width) / total_width
644
+ ax_width = bars_w / total_width
645
+ axes[sub_idx] = fig.add_axes([x_left, y_bottom, ax_width, ax_height])
646
+ x_cursor += sub_w + INTER_COL_GAP
647
+ if layout.legend_width > 0 and legend_position == 'all' and len(stack) > 1:
648
+ x_cursor += layout.legend_width + LEGEND_GAP
649
+
650
+ for idx, (eff_title, df_sub) in enumerate(effective_plots):
651
+ # Prune zero data close to plotting — take a copy so we don't
652
+ # modify the shared effective_plots data.
653
+ if skip_data_with_only_zeroes:
654
+ df_sub = df_sub.copy()
655
+ # Drop all-zero columns (removes empty grouped bars)
656
+ df_sub = df_sub.loc[:, (df_sub.abs() > 1e-6).any(axis=0)]
657
+ # Drop all-zero rows (removes empty bar positions)
658
+ df_sub = df_sub.loc[(df_sub.abs() > 1e-6).any(axis=1)]
659
+
660
+ # Get unique group combinations from df_sub
661
+ if not expand_axis_levels:
662
+ groups = [None]
663
+ elif len(expand_axis_level_names) == 1:
664
+ groups = df_sub.columns.get_level_values(expand_axis_level_names[0]).unique().tolist()
665
+ else:
666
+ # Deduplicate multi-level expand groups
667
+ group_frame = df_sub.columns.to_frame()[expand_axis_level_names].drop_duplicates()
668
+ groups = [tuple(row) for row in group_frame.values]
669
+
670
+ # Reverse ordering for horizontal bars only.
671
+ #
672
+ # In matplotlib horizontal bars, y=0 sits at the bottom of the axes,
673
+ # so emitting bars in reversed data order places the first data item
674
+ # at the top of the plot (natural top-to-bottom reading order). For
675
+ # vertical bars, x=0 sits at the left, so we want the natural data
676
+ # order (first period on the left → left-to-right reading order).
677
+ reverse_order = bar_orientation == 'horizontal'
678
+
679
+ # Reverse groups order (horizontal only — see comment above)
680
+ if expand_axis_levels:
681
+ if reverse_order:
682
+ groups = groups[::-1]
683
+
684
+ # Get bar labels from this subplot's index (not the global df).
685
+ # Each index component is middle-truncated (full text shown on hover).
686
+ subplot_bar_labels = [_format_bar_label(x) for x in df_sub.index]
687
+ subplot_bar_full_labels = [
688
+ _format_bar_label(x, truncate=False) for x in df_sub.index
689
+ ]
690
+ # Reverse to match the reversed groups order (horizontal only)
691
+ if reverse_order:
692
+ subplot_bar_labels = subplot_bar_labels[::-1]
693
+ subplot_bar_full_labels = subplot_bar_full_labels[::-1]
694
+
695
+ # Build list of all bars (for y-axis positioning).
696
+ # Zero pruning already done on df_sub above (if skip_data_with_only_zeroes).
697
+ index_order = df_sub.index[::-1] if reverse_order else df_sub.index
698
+ all_bars = []
699
+ if not expand_axis_levels:
700
+ for idx_val in index_order:
701
+ all_bars.append([None, idx_val])
702
+ else:
703
+ groups_with_bars: list[tuple] = [] # (group, [row_items])
704
+ for group in groups:
705
+ try:
706
+ if not isinstance(df_sub.columns, pd.MultiIndex):
707
+ if group in df_sub.columns:
708
+ df_group = df_sub[[group]]
709
+ else:
710
+ continue
711
+ elif len(expand_axis_level_names) == 1:
712
+ df_group = df_sub.xs(group, level=expand_axis_level_names[0], axis=1)
713
+ else:
714
+ df_group = df_sub.xs(group, level=expand_axis_level_names, axis=1)
715
+ except KeyError:
716
+ continue
717
+ if isinstance(df_group, pd.Series):
718
+ df_group = df_group.to_frame()
719
+ # Per-group row filtering
720
+ if skip_data_with_only_zeroes:
721
+ has_data = (df_group.abs() > 1e-6).any(axis=1)
722
+ row_items = [v for v in index_order
723
+ if v in has_data.index and has_data.loc[v]]
724
+ else:
725
+ row_items = [v for v in index_order
726
+ if v in df_group.index]
727
+ if row_items:
728
+ groups_with_bars.append((group, row_items))
729
+ for idx_val in row_items:
730
+ all_bars.append([group, idx_val])
731
+
732
+ # Skip subplot if no bars have data (avoids zero-height axes)
733
+ if not all_bars:
734
+ if n_subs == 1:
735
+ return None
736
+ if n_subs > 1 and axes[idx] is not None:
737
+ axes[idx].set_visible(False)
738
+ continue
739
+
740
+ # Per-row slot heights. _plot_grouped_bars positions every row's bar
741
+ # group using a SINGLE total_w built from the GLOBAL grouped count
742
+ # (len(grouped_bars) over the full subplot), applied uniformly to all
743
+ # rows. So every row that draws any bar must reserve a slot sized for
744
+ # that same global count — otherwise a row with fewer non-zero
745
+ # scenarios gets a slot smaller than the group it draws and the bars
746
+ # overflow into adjacent rows. We therefore size every non-empty row
747
+ # to _slot_height_for_n_grouped(GLOBAL_n_grouped); only fully-empty
748
+ # rows (no non-zero bar) fall back to BAR_HEIGHT. GLOBAL_n is constant
749
+ # per subplot and equals the len(grouped_bars) the drawing uses.
750
+ global_n_grouped = _count_grouped(df_sub) if grouped_bar_levels else 1
751
+
752
+ def _row_height(row_data: pd.DataFrame) -> float:
753
+ if grouped_bar_levels:
754
+ # Detect a fully-empty row only to fall back to BAR_HEIGHT;
755
+ # the non-empty slot uses the GLOBAL group count to match
756
+ # what _plot_grouped_bars draws.
757
+ if skip_data_with_only_zeroes:
758
+ pruned = row_data.loc[:, (row_data.abs() > 1e-6).any(axis=0)]
759
+ if pruned.empty:
760
+ return BAR_HEIGHT
761
+ elif row_data.empty:
762
+ return BAR_HEIGHT
763
+ return _slot_height_for_n_grouped(global_n_grouped)
764
+ # Simple bars (no grouping) — unchanged.
765
+ if skip_data_with_only_zeroes:
766
+ row_data = row_data.loc[:, (row_data.abs() > 1e-6).any(axis=0)]
767
+ if row_data.empty:
768
+ return BAR_HEIGHT
769
+ return _slot_height_for_n_grouped(1)
770
+
771
+ if expand_axis_levels and groups_with_bars:
772
+ per_bar_heights: list[float] = []
773
+ for group, row_items in groups_with_bars:
774
+ try:
775
+ if not isinstance(df_sub.columns, pd.MultiIndex):
776
+ df_g = df_sub[[group]] if group in df_sub.columns else df_sub
777
+ elif len(expand_axis_level_names) == 1:
778
+ df_g = df_sub.xs(group, level=expand_axis_level_names[0], axis=1)
779
+ else:
780
+ df_g = df_sub.xs(group, level=expand_axis_level_names, axis=1)
781
+ except KeyError:
782
+ df_g = df_sub
783
+ if isinstance(df_g, pd.Series):
784
+ df_g = df_g.to_frame()
785
+ for ri in row_items:
786
+ per_bar_heights.append(_row_height(df_g.loc[[ri]]))
787
+ else:
788
+ per_bar_heights = []
789
+ for _, ri in all_bars:
790
+ per_bar_heights.append(_row_height(df_sub.loc[[ri]]))
791
+
792
+ # Cumulative y-positions (center of each slot)
793
+ y_positions: list[float] = []
794
+ cumulative = 0.0
795
+ for h in per_bar_heights:
796
+ y_positions.append(cumulative + h / 2)
797
+ cumulative += h
798
+ bars_only_h = cumulative
799
+
800
+ # Create figure for single plot (now that we know bar count)
801
+ if n_subs == 1 and fig is None:
802
+ subplot_h = bars_only_h + SUBPLOT_VPAD
803
+ if bar_orientation == 'horizontal':
804
+ fig_w = _single_width
805
+ fig_h = subplot_h + TITLE_PAD + bottom_pad
806
+ legend_excess = 0.0
807
+ if layout and layout.legend_height > 0:
808
+ legend_excess = max(0.0, layout.legend_height - bars_only_h)
809
+ fig_h += legend_excess
810
+ fig = Figure(figsize=(fig_w, fig_h))
811
+ ax = fig.add_axes([
812
+ (layout.total_label_width + left_edge_pad) / fig_w,
813
+ (bottom_pad + legend_excess) / fig_h,
814
+ layout.base_bar_length / fig_w,
815
+ bars_only_h / fig_h,
816
+ ])
817
+ else: # vertical
818
+ fig_w = layout.value_axis_width + bars_only_h + RIGHT_PAD
819
+ if layout.legend_width > 0:
820
+ fig_w += layout.legend_width + LEGEND_GAP
821
+ fig_h = layout.base_bar_length + layout.total_label_width + SUBPLOT_VPAD + TITLE_PAD + bottom_pad
822
+ legend_excess = 0.0
823
+ if layout and layout.legend_height > 0:
824
+ legend_excess = max(0.0, layout.legend_height - layout.base_bar_length)
825
+ fig_h += legend_excess
826
+ fig = Figure(figsize=(fig_w, fig_h))
827
+ ax = fig.add_axes([
828
+ layout.value_axis_width / fig_w,
829
+ (bottom_pad + layout.total_label_width + legend_excess) / fig_h,
830
+ bars_only_h / fig_w,
831
+ layout.base_bar_length / fig_h,
832
+ ])
833
+ axes[0] = ax
834
+ else:
835
+ ax = axes[idx]
836
+
837
+ # When axis_bounds explicitly fixes the value axis, the post-draw
838
+ # finalisation below will override matplotlib's autoscale with
839
+ # those bounds. Passing them in lets the bar-plot helpers compute
840
+ # the sub-pixel threshold against the FINAL on-screen range
841
+ # rather than the data range — important when axis_bounds is
842
+ # tighter than the data (otherwise the helper would compute too
843
+ # large a threshold and drop bars that the final view would show).
844
+ # When no axis_bounds is set, the helper auto-computes from data;
845
+ # downstream zero-inclusion and value-label margin only enlarge
846
+ # that range, so the helper's threshold is a conservative under-
847
+ # estimate (we keep some bars that turn out invisible — safe).
848
+ explicit_bounds = _subplot_axis_bounds(axis_bounds, idx)
849
+ if explicit_bounds and explicit_bounds[0] != explicit_bounds[1]:
850
+ value_axis_lim = (float(explicit_bounds[0]), float(explicit_bounds[1]))
851
+ else:
852
+ value_axis_lim = None
853
+
854
+ # Determine plotting mode and execute appropriate logic
855
+ if grouped_bar_levels:
856
+ # Single batched call per subplot: _plot_grouped_bars vectorises
857
+ # across all bars per category internally (one ax.barh per
858
+ # grouped category, with masked-out zero entries reproducing the
859
+ # original per-row pruning behaviour).
860
+ labeled_groups: set[str] = set()
861
+ _plot_grouped_bars(ax, df_sub, all_bars, expand_axis_level_names,
862
+ grouped_bar_level_names, bar_orientation, value_fmt,
863
+ shared_color_map=shared_color_map,
864
+ y_positions=y_positions,
865
+ slot_heights=per_bar_heights,
866
+ labeled_groups=labeled_groups,
867
+ value_axis_lim=value_axis_lim,
868
+ thickness_mult=thickness_mult)
869
+ elif stack_levels:
870
+ _plot_stacked_bars(ax, df_sub, all_bars, expand_axis_level_names,
871
+ stack_level_names, bar_orientation,
872
+ shared_color_map=shared_color_map,
873
+ y_positions=y_positions, slot_heights=per_bar_heights,
874
+ value_axis_lim=value_axis_lim)
875
+ else:
876
+ _plot_simple_bars(ax, df_sub, all_bars, expand_axis_level_names,
877
+ bar_orientation, value_fmt,
878
+ y_positions=y_positions, slot_heights=per_bar_heights,
879
+ value_axis_lim=value_axis_lim,
880
+ thickness_mult=thickness_mult,
881
+ shared_color_map=shared_color_map,
882
+ color_bar_level=color_bar_level)
883
+
884
+ # Set up axis with groups and bars
885
+ # Build bar labels for display (matching all_bars structure). The
886
+ # expand-group level is folded into each tick label as "bar | group"
887
+ # (group last, like the non-expand "extended" pathway) instead of a
888
+ # separate label to the left of the axis, which collided with long
889
+ # bar labels and inflated the left margin. Full (untruncated) labels
890
+ # are stashed on the axes for the GUI hover tooltip.
891
+ if not expand_axis_levels:
892
+ display_bar_labels = subplot_bar_labels
893
+ full_bar_labels = subplot_bar_full_labels
894
+ else:
895
+ display_bar_labels = [
896
+ _format_bar_label(idx_val, group) for group, idx_val in all_bars
897
+ ]
898
+ full_bar_labels = [
899
+ _format_bar_label(idx_val, group, truncate=False)
900
+ for group, idx_val in all_bars
901
+ ]
902
+ # Expose label data to the embedding GUI (truncated → full) so a
903
+ # hover tooltip can reveal names cut by BAR_LABEL_MAX_CHARS.
904
+ ax._flextool_bar_labels = list(display_bar_labels)
905
+ ax._flextool_bar_labels_full = list(full_bar_labels)
906
+ ax._flextool_bar_positions = list(y_positions)
907
+ ax._flextool_bar_orientation = bar_orientation
908
+
909
+ # Set main axis for individual bars (use cumulative y_positions).
910
+ # Use FixedLocator/FixedFormatter rather than ax.set_yticks(...) so
911
+ # that matplotlib defers Tick object construction until render time
912
+ # (and then only for ticks inside the current view limits). The
913
+ # set_yticks/set_xticks pair otherwise instantiates one Tick per
914
+ # position up-front, which dominates build time when the bar count
915
+ # is in the thousands.
916
+ if bar_orientation == 'horizontal':
917
+ ax.yaxis.set_major_locator(FixedLocator(y_positions))
918
+ ax.yaxis.set_major_formatter(FixedFormatter(display_bar_labels))
919
+ ax.tick_params('y', length=0)
920
+ ax.set_ylim(0, bars_only_h)
921
+ ax.tick_params(labelsize=10)
922
+ else: # vertical
923
+ ax.xaxis.set_major_locator(FixedLocator(y_positions))
924
+ ax.xaxis.set_major_formatter(FixedFormatter(display_bar_labels))
925
+ ax.tick_params('x', length=0)
926
+ ax.set_xlim(0, bars_only_h)
927
+ ax.tick_params(labelsize=10)
928
+ plt.setp(ax.get_xticklabels(), rotation=90, ha='center')
929
+
930
+ if expand_axis_levels and len(groups_with_bars) > 1:
931
+ # The expand-group name is folded into each tick label (above), so
932
+ # we no longer draw a separate group label to the left of the
933
+ # axis (it collided with long bar labels). Keep only thin
934
+ # separators between groups to keep the blocks visually distinct.
935
+ bar_boundaries = [y_positions[0] - per_bar_heights[0] / 2]
936
+ for i in range(len(y_positions)):
937
+ bar_boundaries.append(y_positions[i] + per_bar_heights[i] / 2)
938
+
939
+ group_boundaries = []
940
+ pos_idx = 0
941
+ for _, row_items in groups_with_bars:
942
+ group_boundaries.append(bar_boundaries[pos_idx])
943
+ pos_idx += len(row_items)
944
+ group_boundaries.append(bar_boundaries[-1])
945
+
946
+ # Interior separators only (skip the two outer edges).
947
+ for boundary in group_boundaries[1:-1]:
948
+ if bar_orientation == 'horizontal':
949
+ ax.axhline(y=boundary, color="grey", linewidth=0.8, linestyle="-")
950
+ else:
951
+ ax.axvline(x=boundary, color="grey", linewidth=0.8, linestyle="-")
952
+
953
+ # Subplot title (only for actual subplot dimensions, not the figure title)
954
+ if eff_title is not None:
955
+ ax.set_title(eff_title, pad=2)
956
+
957
+ # Legend
958
+ if stack_levels or grouped_bar_levels:
959
+ handles, labels_leg = ax.get_legend_handles_labels()
960
+
961
+ # Generate legend title based on mode
962
+ if grouped_bar_levels:
963
+ # Legend for grouped bars
964
+ if isinstance(df_sub.columns, pd.MultiIndex):
965
+ legend_title = ' | '.join([str(n) for n in grouped_bar_level_names])
966
+ else:
967
+ legend_title = str(df_sub.columns.name) if df_sub.columns.name else 'group'
968
+ else:
969
+ # Legend for stacked bars
970
+ if isinstance(df_sub.columns, pd.MultiIndex):
971
+ legend_title = ' | '.join([str(n) for n in stack_level_names])
972
+ else:
973
+ legend_title = str(df_sub.columns.name) if df_sub.columns.name else 'stack'
974
+
975
+ if _should_show_legend(legend_position, sub_levels, idx, n_cols, n_subs):
976
+ # Axes width depends on orientation: horizontal uses base_bar_length,
977
+ if bar_orientation == 'horizontal':
978
+ axes_width = layout.base_bar_length
979
+ else:
980
+ axes_width = bars_only_h
981
+ legend_x = 1 + LEGEND_GAP / axes_width
982
+ # _plot_stacked_bars and _plot_grouped_bars build legend entries
983
+ # in the correct visual order — no reversal needed here.
984
+ ax.legend(handles, labels_leg, title=legend_title,
985
+ bbox_to_anchor=(legend_x, 1), loc='upper left', borderaxespad=0)
986
+
987
+ row = idx // n_cols
988
+ col = idx % n_cols
989
+ if always_include_zero_in_axis:
990
+ if bar_orientation == 'horizontal':
991
+ lo, hi = ax.get_xlim()
992
+ ax.set_xlim(min(lo, 0), max(hi, 0))
993
+ else:
994
+ lo, hi = ax.get_ylim()
995
+ ax.set_ylim(min(lo, 0), max(hi, 0))
996
+ scale = _subplot_axis_bounds(axis_bounds, idx)
997
+ if scale and scale[0] != scale[1]:
998
+ if bar_orientation == 'horizontal':
999
+ ax.set_xlim(scale[0], scale[1])
1000
+ else:
1001
+ ax.set_ylim(scale[0], scale[1])
1002
+ _fmt = _get_value_formatter(axis_tick_format, idx)
1003
+ # Prune the outermost tick (away from zero) to prevent label overflow.
1004
+ if bar_orientation == 'horizontal':
1005
+ lo, hi = ax.get_xlim()
1006
+ # Prune the end farthest from zero
1007
+ if abs(hi) >= abs(lo):
1008
+ prune = 'upper'
1009
+ else:
1010
+ prune = 'lower'
1011
+ nbins = _estimate_value_nbins(lo, hi, layout.base_bar_length, _fmt, is_horizontal_axis=True)
1012
+ ax.xaxis.set_major_locator(MaxNLocator(nbins=nbins, prune=prune))
1013
+ ax.xaxis.set_major_formatter(_fmt)
1014
+ else:
1015
+ lo, hi = ax.get_ylim()
1016
+ if abs(hi) >= abs(lo):
1017
+ prune = 'upper'
1018
+ else:
1019
+ prune = 'lower'
1020
+ nbins = _estimate_value_nbins(lo, hi, layout.base_bar_length, _fmt, is_horizontal_axis=False)
1021
+ ax.yaxis.set_major_locator(MaxNLocator(nbins=nbins, prune=prune))
1022
+ ax.yaxis.set_major_formatter(_fmt)
1023
+ # Extend axis limits AFTER tick locator to create room for value labels
1024
+ # within the plot area. The vertical branch keeps the simple 13% bump
1025
+ # (label overflow there is one text-line tall — negligible). The
1026
+ # horizontal branch reserves room sized to the *actual* value-label
1027
+ # text width in inches, because a fixed % of the data range under-
1028
+ # reserves for large numbers (the label width is ~constant in inches,
1029
+ # not proportional to the data range) and the labels spill into the
1030
+ # legend. It never shrinks below the old 13%.
1031
+ if value_fmt:
1032
+ if bar_orientation == 'horizontal':
1033
+ data_min, data_max = ax.get_xlim()
1034
+ # The label sits at the data extreme; reserve its text width.
1035
+ neg_anchor = min(data_min, 0)
1036
+
1037
+ def _value_label_text(v):
1038
+ if value_fmt == 'dynamic':
1039
+ return format_value_label(v)
1040
+ return format(v, value_fmt)
1041
+
1042
+ # Per-char width slightly generous vs the font-9-calibrated
1043
+ # CHAR_WIDTH (=0.081"): bar_label font is ~10pt and CHAR_WIDTH
1044
+ # under-estimates it. Over-reserving a little is far better
1045
+ # than clipping into the legend. Add the 3pt bar_label padding.
1046
+ _label_char_w = 0.095 # inches per char at the label font
1047
+ _label_pad_in = 3 / 72 # bar_label padding=3pt
1048
+ _axw = layout.base_bar_length
1049
+
1050
+ xmin, xmax = data_min, data_max
1051
+ if _axw > 0:
1052
+ if data_max > 0:
1053
+ label_w_in = len(_value_label_text(data_max)) * _label_char_w + _label_pad_in
1054
+ frac = min(0.9, label_w_in / _axw)
1055
+ new_xmax = (data_max - label_w_in * neg_anchor / _axw) / (1 - frac)
1056
+ xmax = max(data_max * 1.13, new_xmax)
1057
+ if data_min < 0:
1058
+ label_w_neg = len(_value_label_text(data_min)) * _label_char_w + _label_pad_in
1059
+ frac_neg = min(0.9, label_w_neg / _axw)
1060
+ # Mirror of the positive solve: reserve label_w_neg to
1061
+ # the left of data_min so the label fits in the axes.
1062
+ pos_anchor = max(data_max, 0)
1063
+ new_xmin = (data_min - label_w_neg * pos_anchor / _axw) / (1 - frac_neg)
1064
+ xmin = min(data_min * 1.13, new_xmin)
1065
+ else:
1066
+ if data_max > 0:
1067
+ xmax = data_max * 1.13
1068
+ if data_min < 0:
1069
+ xmin = data_min * 1.13
1070
+ ax.set_xlim(xmin, xmax)
1071
+ else:
1072
+ ymin, ymax = ax.get_ylim()
1073
+ if ymax > 0:
1074
+ ymax *= 1.13
1075
+ if ymin < 0:
1076
+ ymin *= 1.13
1077
+ ax.set_ylim(ymin, ymax)
1078
+ # Y-axis label positioning. For HORIZONTAL bars the ylabel is the
1079
+ # category axis (left of the tick labels): pin it explicitly to the
1080
+ # left of the (generously estimated) tick-label region — and, when
1081
+ # expand-axis group labels occupy space to the left, to the left of
1082
+ # those too. Explicit set_label_coords avoids matplotlib's
1083
+ # environment-dependent auto-positioning. For VERTICAL bars the ylabel
1084
+ # is the value axis; keep the existing labelpad behaviour unchanged.
1085
+ if bar_orientation == 'horizontal':
1086
+ # Inches reserved between figure-left and the axes' left spine
1087
+ # (tick labels + ylabel reservation), minus LEFT_PAD which we keep
1088
+ # as figure-edge breathing room. Used to clamp the ylabel so its
1089
+ # text never crosses the figure's left edge for very wide category
1090
+ # labels.
1091
+ left_margin_in = layout.total_label_width + left_edge_pad - LEFT_PAD
1092
+ ylabel_axes_x = _ylabel_axes_x(
1093
+ layout.bar_label_width, layout.base_bar_length,
1094
+ left_margin_in=left_margin_in,
1095
+ )
1096
+ _apply_subplot_label(ax, xlabel, ylabel, idx, row, col, n_rows,
1097
+ ylabel_axes_x=ylabel_axes_x)
1098
+ else:
1099
+ if expand_axis_levels:
1100
+ expand_pad = 0.15 * 72
1101
+ else:
1102
+ expand_pad = 18
1103
+ _apply_subplot_label(ax, xlabel, ylabel, idx, row, col, n_rows,
1104
+ expand_label_pad=expand_pad)
1105
+
1106
+ # Add a dotted zero line on the value axis
1107
+ if bar_orientation == 'horizontal':
1108
+ ax.axvline(0, color='black', linewidth=0.5, linestyle=':')
1109
+ else:
1110
+ ax.axhline(0, color='black', linewidth=0.5, linestyle=':')
1111
+
1112
+ # ── Shared legend (one per file, anchored to top-right subplot) ──
1113
+ if legend_position == 'shared' and shared_color_map and (stack_levels or grouped_bar_levels):
1114
+ from matplotlib.patches import Patch
1115
+ legend_ax_idx = min(n_cols - 1, n_subs - 1)
1116
+ ax_legend = axes[legend_ax_idx]
1117
+ handles = [Patch(facecolor=c) for c in shared_color_map.values()]
1118
+ labels_all = list(shared_color_map.keys())
1119
+
1120
+ # Generate legend title
1121
+ if grouped_bar_levels:
1122
+ if isinstance(df.columns, pd.MultiIndex):
1123
+ legend_title = ' | '.join(str(n) for n in grouped_bar_level_names)
1124
+ else:
1125
+ legend_title = str(df.columns.name) if df.columns.name else 'group'
1126
+ else:
1127
+ if isinstance(df.columns, pd.MultiIndex):
1128
+ legend_title = ' | '.join(str(n) for n in stack_level_names)
1129
+ else:
1130
+ legend_title = str(df.columns.name) if df.columns.name else 'stack'
1131
+
1132
+ if bar_orientation == 'horizontal':
1133
+ axes_width = layout.base_bar_length
1134
+ else:
1135
+ axes_width = bars_only_h if bars_only_h > 0 else 1.0
1136
+ legend_x = 1 + LEGEND_GAP / axes_width
1137
+ ax_legend.legend(handles, labels_all, title=legend_title,
1138
+ bbox_to_anchor=(legend_x, 1), loc='upper left', borderaxespad=0)
1139
+
1140
+ # Hide unused subplots
1141
+ for idx in range(n_subs, len(axes)):
1142
+ axes[idx].set_visible(False)
1143
+
1144
+ # Figure title at a fixed distance from the top (10px ≈ 0.14in at 72 dpi)
1145
+ fig_h = fig.get_size_inches()[1]
1146
+ fig.suptitle(key_name, y=1 - 0.14 / fig_h, va='top')
1147
+
1148
+ return fig
1149
+
1150
+
1151
+ def _render_bar_figure(
1152
+ effective_plots, df, key_name, plot_dir,
1153
+ stack_levels, stack_level_names,
1154
+ expand_axis_levels, expand_axis_level_names,
1155
+ sub_levels,
1156
+ grouped_bar_levels, grouped_bar_level_names,
1157
+ legend_position, subplots_per_row,
1158
+ xlabel, ylabel, bar_orientation, base_bar_length,
1159
+ value_fmt, axis_bounds, axis_tick_format,
1160
+ always_include_zero_in_axis, output_filepath,
1161
+ layout: BarLayoutParams | None = None,
1162
+ shared_color_map: dict[str, tuple] | None = None,
1163
+ skip_data_with_only_zeroes: bool = False,
1164
+ ):
1165
+ """Render one file's worth of bar subplots and save to disk."""
1166
+ fig = _build_bar_figure(
1167
+ effective_plots, df, key_name, plot_dir,
1168
+ stack_levels, stack_level_names,
1169
+ expand_axis_levels, expand_axis_level_names,
1170
+ sub_levels,
1171
+ grouped_bar_levels, grouped_bar_level_names,
1172
+ legend_position, subplots_per_row,
1173
+ xlabel, ylabel, bar_orientation, base_bar_length,
1174
+ value_fmt, axis_bounds, axis_tick_format,
1175
+ always_include_zero_in_axis,
1176
+ layout, shared_color_map, skip_data_with_only_zeroes,
1177
+ )
1178
+ if fig is None:
1179
+ return
1180
+ filepath = output_filepath or f'{plot_dir}/{key_name}_d.png'
1181
+ fig.savefig(filepath)
1182
+ plt.close(fig)
1183
+
1184
+
1185
+ def build_bar_figures(
1186
+ df: pd.DataFrame,
1187
+ key_name: str,
1188
+ plot_dir: str,
1189
+ stack_levels: list[int],
1190
+ expand_axis_levels: list[int],
1191
+ sub_levels: list[int] | None = None,
1192
+ grouped_bar_levels: list[int] | None = None,
1193
+ legend_position: str = 'right',
1194
+ subplots_per_row: int = 2,
1195
+ xlabel: str | None = None,
1196
+ ylabel: str | None = None,
1197
+ bar_orientation: str = 'horizontal',
1198
+ base_bar_length: float = 4,
1199
+ value_label=False,
1200
+ axis_bounds=None,
1201
+ axis_tick_format='1,.0f',
1202
+ always_include_zero_in_axis: bool = True,
1203
+ max_items_per_plot: int = 10,
1204
+ max_subplots_per_file: int = 6,
1205
+ max_items_per_subplot_column: int = 40,
1206
+ only_first_file: bool = False,
1207
+ skip_data_with_only_zeroes: bool = False,
1208
+ only_file_index: int | None = None,
1209
+ *,
1210
+ color_template: dict | None = None,
1211
+ category: str | None = None,
1212
+ entity_class: str | None = None,
1213
+ ) -> tuple[list[tuple[str, 'plt.Figure']], int]:
1214
+ """Build bar-chart Figures and return them without saving or closing.
1215
+
1216
+ Returns (figures, total_file_count) where figures is a list of
1217
+ (batch_title, Figure) pairs -- one per file batch.
1218
+ Figures where all data is empty are omitted (not returned as None).
1219
+ When only_file_index is set, only that batch is built.
1220
+ """
1221
+ if sub_levels is None:
1222
+ sub_levels = []
1223
+
1224
+ # Validate mutual exclusivity
1225
+ if stack_levels and grouped_bar_levels:
1226
+ raise ValueError(
1227
+ "Cannot use both 'stack_levels' and 'grouped_bar_levels' simultaneously."
1228
+ )
1229
+
1230
+ # Resolve value_label
1231
+ if value_label is True or value_label == 'true':
1232
+ value_fmt = 'dynamic'
1233
+ elif value_label:
1234
+ value_fmt = str(value_label)
1235
+ else:
1236
+ value_fmt = None
1237
+
1238
+ if stack_levels is None:
1239
+ stack_levels = []
1240
+ if grouped_bar_levels is None:
1241
+ grouped_bar_levels = []
1242
+
1243
+ # Convert level indices to names
1244
+ if isinstance(df.columns, pd.MultiIndex):
1245
+ stack_level_names = [df.columns.names[i] for i in stack_levels] if stack_levels else []
1246
+ expand_axis_level_names = [df.columns.names[i] for i in expand_axis_levels] if expand_axis_levels else []
1247
+ grouped_bar_level_names = [df.columns.names[i] for i in grouped_bar_levels] if grouped_bar_levels else []
1248
+ else:
1249
+ stack_level_names = stack_levels
1250
+ expand_axis_level_names = [df.columns.name] if expand_axis_levels else []
1251
+ grouped_bar_level_names = [df.columns.name] if grouped_bar_levels else []
1252
+
1253
+ subs = _sort_subs(_get_unique_levels(df.columns, sub_levels))
1254
+
1255
+ # Compute expand-group count. Mirrors the fix in ``_compute_bar_plan``:
1256
+ # single-level columns also fan out into one bar per unique value at
1257
+ # draw time, so we count those as expand groups too. Without this the
1258
+ # pagination undercounts and a single figure ends up gigapixel-sized.
1259
+ if expand_axis_levels:
1260
+ if isinstance(df.columns, pd.MultiIndex):
1261
+ if len(expand_axis_level_names) == 1:
1262
+ expand_level_name = expand_axis_level_names[0]
1263
+ n_expand_groups = len(df.columns.get_level_values(expand_level_name).unique())
1264
+ else:
1265
+ expand_level_name = expand_axis_level_names[0]
1266
+ expand_frame = df.columns.to_frame()[expand_axis_level_names].drop_duplicates()
1267
+ n_expand_groups = len(expand_frame)
1268
+ else:
1269
+ expand_level_name = df.columns.name
1270
+ n_expand_groups = len(df.columns.unique())
1271
+ else:
1272
+ expand_level_name = None
1273
+ n_expand_groups = 1
1274
+
1275
+ # When a simple bar is colored by a SUBPLOT-level entity (e.g. reserve
1276
+ # slack, colored by its node group), the colour value is the subplot's
1277
+ # own value — which _extract_subplot_data drops below. The plan/cached
1278
+ # render path keeps it (it reconstructs df_sub with every level), so
1279
+ # re-attach it here as a constant column level to match that shape; the
1280
+ # per-bar lookup then reads it uniformly in both paths. Resolved up front
1281
+ # (cheap, name-only) so we know whether to re-attach during extraction.
1282
+ reattach_sub_names: list[str] = []
1283
+ reattach_all_consumed = False
1284
+ if entity_class and not (stack_levels or grouped_bar_levels) and sub_levels:
1285
+ from flextool.plot_outputs.plan import resolve_color_bar_level
1286
+ col_nm = list(df.columns.names) if isinstance(df.columns, pd.MultiIndex) else [df.columns.name]
1287
+ idx_nm = list(df.index.names) if isinstance(df.index, pd.MultiIndex) else [df.index.name]
1288
+ early_level = resolve_color_bar_level(idx_nm + col_nm, entity_class)
1289
+ sub_names = [col_nm[i] for i in sub_levels]
1290
+ if early_level in sub_names:
1291
+ reattach_sub_names = sub_names
1292
+ reattach_all_consumed = len(sub_levels) == len(col_nm)
1293
+
1294
+ # Build effective_plots — split subplots that exceed max_items_per_plot.
1295
+ # The "items" are visual bar-label rows: n_rows * n_expand_groups.
1296
+ # Splitting can happen by expand groups, by rows, or both.
1297
+ effective_plots: list[tuple[str | None, pd.DataFrame]] = []
1298
+ for sub in subs:
1299
+ df_sub = _extract_subplot_data(df, sub, sub_levels)
1300
+ if reattach_sub_names and sub is not None:
1301
+ df_sub = _reattach_constant_levels(
1302
+ df_sub, sub, reattach_sub_names, reattach_all_consumed)
1303
+ df_sub = df_sub.dropna(how='all')
1304
+ if df_sub.empty:
1305
+ continue
1306
+ df_sub = df_sub.fillna(0)
1307
+ title = (
1308
+ ' | '.join(str(v) for v in sub) if isinstance(sub, tuple)
1309
+ else str(sub) if sub is not None else None
1310
+ )
1311
+ n_rows = len(df_sub)
1312
+
1313
+ if not max_items_per_plot:
1314
+ effective_plots.append((title, df_sub))
1315
+ continue
1316
+
1317
+ # Total visual items = n_rows * n_expand_groups
1318
+ total_items = n_rows * max(n_expand_groups, 1)
1319
+ if total_items <= max_items_per_plot:
1320
+ effective_plots.append((title, df_sub))
1321
+ continue
1322
+
1323
+ # Split by expand groups first (if present), then by rows within each group chunk
1324
+ if expand_level_name is not None and n_expand_groups > 1:
1325
+ max_groups = max(1, max_items_per_plot // max(n_rows, 1))
1326
+ if isinstance(df_sub.columns, pd.MultiIndex):
1327
+ all_groups = df_sub.columns.get_level_values(expand_level_name).unique().tolist()
1328
+ else:
1329
+ all_groups = df_sub.columns.unique().tolist()
1330
+ for gi, grp_start in enumerate(range(0, len(all_groups), max_groups)):
1331
+ grp_chunk = all_groups[grp_start:grp_start + max_groups]
1332
+ if isinstance(df_sub.columns, pd.MultiIndex):
1333
+ mask = df_sub.columns.get_level_values(expand_level_name).isin(grp_chunk)
1334
+ else:
1335
+ mask = df_sub.columns.isin(grp_chunk)
1336
+ chunk = df_sub.loc[:, mask]
1337
+ if n_rows > max_items_per_plot:
1338
+ n_row_chunks = (n_rows + max_items_per_plot - 1) // max_items_per_plot
1339
+ for ri in range(n_row_chunks):
1340
+ row_chunk = chunk.iloc[ri * max_items_per_plot:(ri + 1) * max_items_per_plot]
1341
+ sub_label = (
1342
+ f"{title}_{gi + 1}.{ri + 1}"
1343
+ if title else f"{gi + 1}.{ri + 1}"
1344
+ )
1345
+ effective_plots.append((sub_label, row_chunk))
1346
+ else:
1347
+ chunk_label = f"{title}_{gi + 1}" if title else None
1348
+ effective_plots.append((chunk_label, chunk))
1349
+ elif n_rows > max_items_per_plot:
1350
+ # No expand groups — split by rows
1351
+ for i in range(0, n_rows, max_items_per_plot):
1352
+ chunk = df_sub.iloc[i:i + max_items_per_plot]
1353
+ chunk_label = f"{title}_{i // max_items_per_plot + 1}" if title else None
1354
+ effective_plots.append((chunk_label, chunk))
1355
+ else:
1356
+ effective_plots.append((title, df_sub))
1357
+
1358
+ if not effective_plots:
1359
+ return [], 0
1360
+
1361
+ # Build shared color map
1362
+ shared_color_map = None
1363
+ color_bar_level = None
1364
+ if (legend_position == 'shared' or category or entity_class) and (stack_levels or grouped_bar_levels):
1365
+ all_labels: list[str] = []
1366
+ for _, df_sub in effective_plots:
1367
+ if grouped_bar_levels:
1368
+ if len(grouped_bar_level_names) == 1:
1369
+ items = df_sub.columns.get_level_values(
1370
+ grouped_bar_level_names[0]).unique().tolist()
1371
+ else:
1372
+ item_df = df_sub.columns.to_frame()[grouped_bar_level_names].drop_duplicates()
1373
+ items = [tuple(row) for row in item_df.values]
1374
+ else:
1375
+ if len(stack_level_names) == 1:
1376
+ if isinstance(df_sub.columns, pd.MultiIndex):
1377
+ items = df_sub.columns.get_level_values(
1378
+ stack_level_names[0]).unique().tolist()
1379
+ else:
1380
+ items = df_sub.columns.unique().tolist()
1381
+ else:
1382
+ stack_df = df_sub.columns.to_frame()[stack_level_names].drop_duplicates()
1383
+ items = [tuple(row) for row in stack_df.values]
1384
+ for item in items:
1385
+ label = _format_legend_labels([item])[0]
1386
+ if label not in all_labels:
1387
+ all_labels.append(label)
1388
+ # Listed labels first in file order; unlisted appended alphabetically.
1389
+ all_labels = order_labels_by_template(
1390
+ all_labels,
1391
+ color_template or {},
1392
+ category=category,
1393
+ entity_class=entity_class,
1394
+ )
1395
+ shared_color_map = build_shared_color_map(
1396
+ all_labels,
1397
+ color_template=color_template,
1398
+ category=category,
1399
+ entity_class=entity_class,
1400
+ )
1401
+ elif entity_class and not (stack_levels or grouped_bar_levels):
1402
+ # Simple bars: color by the level carrying entity_class (column or
1403
+ # row index). Shared with the plan path for identical resolution.
1404
+ from flextool.plot_outputs.plan import build_simple_bar_color_map
1405
+ shared_color_map, color_bar_level = build_simple_bar_color_map(
1406
+ df, entity_class, color_template,
1407
+ )
1408
+
1409
+ # Compute layout
1410
+ layout = _compute_bar_layout(
1411
+ effective_plots, df,
1412
+ expand_axis_levels, expand_axis_level_names,
1413
+ stack_levels, stack_level_names,
1414
+ grouped_bar_levels, grouped_bar_level_names,
1415
+ legend_position, subplots_per_row,
1416
+ base_bar_length,
1417
+ )
1418
+
1419
+ # Split into file batches respecting max_subplots_per_file and, for
1420
+ # horizontal bars, max_items_per_subplot_column.
1421
+ spr = max(subplots_per_row, 1)
1422
+ _max = max_subplots_per_file if max_subplots_per_file else len(effective_plots)
1423
+ col_limit = (
1424
+ max_items_per_subplot_column
1425
+ if bar_orientation == 'horizontal' else 0
1426
+ )
1427
+
1428
+ # Compute the effective visual item count per subplot. When expand
1429
+ # groups are present, each group contributes its own set of bar labels,
1430
+ # so the count is much larger than len(df_sub).
1431
+ def _count_visual_items(df_sub: pd.DataFrame) -> int:
1432
+ if not expand_axis_level_names:
1433
+ return max(len(df_sub), 1)
1434
+ if not isinstance(df_sub.columns, pd.MultiIndex):
1435
+ return max(len(df_sub) * len(df_sub.columns.unique()), 1)
1436
+ count = 0
1437
+ if len(expand_axis_level_names) == 1:
1438
+ groups = df_sub.columns.get_level_values(expand_axis_level_names[0]).unique()
1439
+ else:
1440
+ gf = df_sub.columns.to_frame()[expand_axis_level_names].drop_duplicates()
1441
+ groups = [tuple(r) for r in gf.values]
1442
+ for grp in groups:
1443
+ try:
1444
+ if len(expand_axis_level_names) == 1:
1445
+ df_g = df_sub.xs(grp, level=expand_axis_level_names[0], axis=1)
1446
+ else:
1447
+ df_g = df_sub.xs(grp, level=expand_axis_level_names, axis=1)
1448
+ except KeyError:
1449
+ continue
1450
+ if isinstance(df_g, pd.Series):
1451
+ df_g = df_g.to_frame()
1452
+ count += len(df_g)
1453
+ return max(count, 1)
1454
+
1455
+ visual_item_counts = [_count_visual_items(df_sub) for _, df_sub in effective_plots]
1456
+
1457
+ # Group subplots into grid rows so we never break mid-row.
1458
+ grid_rows: list[list[int]] = []
1459
+ for gi in range(0, len(effective_plots), spr):
1460
+ grid_rows.append(list(range(gi, min(gi + spr, len(effective_plots)))))
1461
+
1462
+ _file_batches: list[tuple[list, None]] = []
1463
+ cur: list = []
1464
+ col_counts = [0] * spr
1465
+
1466
+ for row_indices in grid_rows:
1467
+ would_exceed_subplots = len(cur) + len(row_indices) > _max
1468
+ would_exceed_col = False
1469
+ if col_limit and cur:
1470
+ for j, idx in enumerate(row_indices):
1471
+ if col_counts[j] + visual_item_counts[idx] > col_limit:
1472
+ would_exceed_col = True
1473
+ break
1474
+
1475
+ if (would_exceed_subplots or would_exceed_col) and cur:
1476
+ _file_batches.append((cur, None))
1477
+ cur = []
1478
+ col_counts = [0] * spr
1479
+
1480
+ cur.extend([effective_plots[i] for i in row_indices])
1481
+ for j, idx in enumerate(row_indices):
1482
+ col_counts[j] += visual_item_counts[idx]
1483
+
1484
+ if cur:
1485
+ _file_batches.append((cur, None))
1486
+
1487
+ total_file_count = len(_file_batches)
1488
+ batches_to_build = _file_batches[:1] if only_first_file else _file_batches
1489
+ n_total_batches = len(_file_batches)
1490
+ result: list[tuple[str, plt.Figure]] = []
1491
+ for batch_idx, (batch, _) in enumerate(batches_to_build, start=1):
1492
+ if only_file_index is not None and (batch_idx - 1) != only_file_index:
1493
+ continue
1494
+ batch_title = f"{key_name} ({batch_idx}/{n_total_batches})" if n_total_batches > 1 else key_name
1495
+ fig = _build_bar_figure(
1496
+ batch, df, batch_title, plot_dir,
1497
+ stack_levels, stack_level_names,
1498
+ expand_axis_levels, expand_axis_level_names,
1499
+ sub_levels,
1500
+ grouped_bar_levels, grouped_bar_level_names,
1501
+ legend_position, subplots_per_row,
1502
+ xlabel, ylabel, bar_orientation, base_bar_length,
1503
+ value_fmt, axis_bounds, axis_tick_format,
1504
+ always_include_zero_in_axis,
1505
+ layout, shared_color_map, skip_data_with_only_zeroes,
1506
+ color_bar_level=color_bar_level,
1507
+ )
1508
+ if fig is not None:
1509
+ result.append((batch_title, fig))
1510
+ return result, total_file_count