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,753 @@
1
+ """
2
+ Bar chart rendering functions for each bar mode.
3
+
4
+ Called by plot_bars.py (and currently plot_functions.py during transition).
5
+ Each function receives an axes object and renders one subplot's bars.
6
+ """
7
+ import numpy as np
8
+ import pandas as pd
9
+ import matplotlib.pyplot as plt
10
+ from flextool.plot_outputs.format_helpers import format_value_label
11
+
12
+ # Bar geometry constants (kept in sync with plot_bars.py module-level
13
+ # values). Duplicated here to avoid a circular import — plot_bars.py
14
+ # imports the rendering functions from this module.
15
+ BAR_GAP_FRACTION = 0.10
16
+ REFERENCE_BAR_THICKNESS = 0.0558 # matches plot_bars.REFERENCE_BAR_THICKNESS
17
+ SOLO_BAR_THICKNESS = 0.1116 # = 2 × REFERENCE_BAR_THICKNESS
18
+
19
+ # Default fill for simple bars when the plot doesn't opt into entity coloring
20
+ # (no color_entity_class), and the fallback for an unresolved entity value.
21
+ DEFAULT_SIMPLE_BAR_COLOR = 'steelblue'
22
+
23
+
24
+ def _min_visible_data_width(ax, axis: str) -> float:
25
+ """Smallest data-axis width that still renders at >= 1 display pixel.
26
+
27
+ Returns the data extent that maps to one pixel along *axis* in the
28
+ current axes layout: ``(xlim[1] - xlim[0]) / ax.bbox.width`` for the
29
+ x-axis, ``(ylim[1] - ylim[0]) / ax.bbox.height`` for y.
30
+
31
+ Bars whose width (or height, for vertical bars) is strictly less than
32
+ this value cannot light up a pixel and can be skipped from drawing
33
+ without changing the visible output. If a zoom changes xlim/ylim
34
+ later, callers can re-evaluate the threshold and re-render.
35
+ """
36
+ if axis == 'x':
37
+ lo, hi = ax.get_xlim()
38
+ px = ax.bbox.width
39
+ elif axis == 'y':
40
+ lo, hi = ax.get_ylim()
41
+ px = ax.bbox.height
42
+ else:
43
+ raise ValueError(f"axis must be 'x' or 'y', got {axis!r}")
44
+ if px <= 0:
45
+ return 0.0
46
+ return abs(hi - lo) / float(px)
47
+
48
+
49
+ def _autoscale_value_range(values: np.ndarray) -> tuple[float, float]:
50
+ """Reproduce matplotlib's auto-xlim for a bar() / barh() value array.
51
+
52
+ Matplotlib expands the axis to include 0 (bars baseline) plus the data
53
+ extent, then adds a small margin. We mirror that here so callers can
54
+ set xlim before drawing and read a threshold from ax.bbox. The exact
55
+ margin matches rcParams['axes.xmargin'] (default 0.05).
56
+ """
57
+ if values.size == 0:
58
+ return 0.0, 1.0
59
+ vmin = np.nanmin(values)
60
+ vmax = np.nanmax(values)
61
+ if not (np.isfinite(vmin) and np.isfinite(vmax)):
62
+ return 0.0, 1.0
63
+ lo = float(min(0.0, vmin))
64
+ hi = float(max(0.0, vmax))
65
+ if lo == hi:
66
+ return lo - 0.5, hi + 0.5
67
+ margin = 0.05 * (hi - lo)
68
+ return lo - margin, hi + margin
69
+
70
+
71
+ def _stack_label(stack_idx: int, stacks: list, labeled_stacks: set) -> str:
72
+ """Return the legend label for a stack segment (empty string if already labeled)."""
73
+ if stack_idx not in labeled_stacks:
74
+ stack_value = stacks[stack_idx]
75
+ label = (
76
+ ' | '.join(str(v) for v in stack_value)
77
+ if isinstance(stack_value, (tuple, list))
78
+ else str(stack_value)
79
+ )
80
+ labeled_stacks.add(stack_idx)
81
+ else:
82
+ label = ''
83
+ return label
84
+
85
+
86
+ def _plot_grouped_bars(
87
+ ax,
88
+ df_sub: pd.DataFrame,
89
+ all_bars: list,
90
+ expand_axis_level_names: list,
91
+ grouped_bar_level_names: list,
92
+ bar_orientation: str,
93
+ value_fmt: str | None,
94
+ shared_color_map: dict[str, tuple] | None = None,
95
+ y_positions: list[float] | None = None,
96
+ slot_heights: list[float] | None = None,
97
+ labeled_groups: set[str] | None = None,
98
+ value_axis_lim: tuple[float, float] | None = None,
99
+ thickness_mult: float = 1.0,
100
+ ) -> None:
101
+ """Render grouped side-by-side bars onto ax for one subplot.
102
+
103
+ Vectorised across all_bars: one ax.barh / ax.bar call per grouped
104
+ category. Each call takes a vector of sub-y-positions (one per bar) and
105
+ a vector of widths; bars with zero value in this category are masked
106
+ out before the draw call, mirroring the original per-bar skip behaviour
107
+ when ``skip_data_with_only_zeroes`` had already pruned them at the
108
+ call site.
109
+ """
110
+ if not all_bars:
111
+ return
112
+
113
+ # Get grouped bar combinations
114
+ if len(grouped_bar_level_names) == 1:
115
+ grouped_bars = df_sub.columns.get_level_values(grouped_bar_level_names[0]).unique().tolist()
116
+ else:
117
+ grouped_bar_df = df_sub.columns.to_frame()[grouped_bar_level_names].drop_duplicates()
118
+ grouped_bars = [tuple(row) for row in grouped_bar_df.values]
119
+
120
+ # Order groups by the shared color map's key order (file order from
121
+ # plot_settings.yaml; unlisted labels were appended alphabetically by the
122
+ # plan), matching the stack/line charts so the visual order tracks the
123
+ # legend and the picker's reordering. Groups missing from the map keep
124
+ # their relative position after the mapped ones.
125
+ if shared_color_map:
126
+ def _group_key(gb):
127
+ return ' | '.join(str(v) for v in gb) if isinstance(gb, (tuple, list)) else str(gb)
128
+ key_pos = {k: i for i, k in enumerate(shared_color_map.keys())}
129
+ orig = list(grouped_bars)
130
+ grouped_bars.sort(
131
+ key=lambda gb: (
132
+ key_pos.get(_group_key(gb), len(key_pos)), orig.index(gb),
133
+ ),
134
+ )
135
+
136
+ # Colors for grouped bars
137
+ n_grouped = len(grouped_bars)
138
+ if shared_color_map:
139
+ colors = []
140
+ for gb in grouped_bars:
141
+ label = ' | '.join(str(v) for v in gb) if isinstance(gb, (tuple, list)) else str(gb)
142
+ colors.append(shared_color_map.get(label, (0.5, 0.5, 0.5)))
143
+ else:
144
+ colors = list(plt.colormaps['tab10'].colors[:n_grouped])
145
+ if n_grouped > 10:
146
+ colors = list(plt.colormaps['tab20'].colors[:n_grouped])
147
+
148
+ # Build (n_bars, n_grouped) value matrix via cached per-group slices.
149
+ n_bars = len(all_bars)
150
+ values_mat = np.zeros((n_bars, n_grouped), dtype=float)
151
+ has_value = np.zeros((n_bars, n_grouped), dtype=bool)
152
+
153
+ def _hashable_key(g):
154
+ if isinstance(g, list):
155
+ return tuple(g)
156
+ return g
157
+
158
+ group_df_cache: dict = {}
159
+ group_cat_series: dict = {}
160
+
161
+ def _get_cat_series(df_bar, grouped_bar):
162
+ if isinstance(df_bar, pd.Series):
163
+ return df_bar
164
+ if isinstance(df_bar.columns, pd.MultiIndex):
165
+ try:
166
+ if len(grouped_bar_level_names) == 1:
167
+ df_grouped = df_bar.xs(grouped_bar, level=grouped_bar_level_names[0], axis=1)
168
+ else:
169
+ df_grouped = df_bar.xs(grouped_bar, level=grouped_bar_level_names, axis=1)
170
+ except KeyError:
171
+ return None
172
+ else:
173
+ if grouped_bar in df_bar.columns:
174
+ df_grouped = df_bar[grouped_bar]
175
+ else:
176
+ return None
177
+ if isinstance(df_grouped, pd.DataFrame):
178
+ df_grouped = df_grouped.sum(axis=1)
179
+ return df_grouped
180
+
181
+ for bar_idx, (group, period) in enumerate(all_bars):
182
+ gkey = _hashable_key(group)
183
+ if gkey not in group_df_cache:
184
+ if group is None:
185
+ df_bar = df_sub
186
+ elif len(expand_axis_level_names) == 1 and isinstance(df_sub.columns, pd.MultiIndex):
187
+ df_bar = df_sub.xs(group, level=expand_axis_level_names[0], axis=1)
188
+ elif len(expand_axis_level_names) == 1:
189
+ df_bar = df_sub[group]
190
+ else:
191
+ df_bar = df_sub.xs(group, level=expand_axis_level_names, axis=1)
192
+ if isinstance(df_bar, pd.Series):
193
+ df_bar = df_bar.to_frame()
194
+ group_df_cache[gkey] = df_bar
195
+ df_bar = group_df_cache[gkey]
196
+
197
+ for grouped_idx, grouped_bar in enumerate(grouped_bars):
198
+ cache_key = (gkey, grouped_idx)
199
+ if cache_key not in group_cat_series:
200
+ group_cat_series[cache_key] = _get_cat_series(df_bar, grouped_bar)
201
+ sums = group_cat_series[cache_key]
202
+ if sums is None:
203
+ continue
204
+ if period in sums.index:
205
+ values_mat[bar_idx, grouped_idx] = float(sums.loc[period])
206
+ # Preserve previous "skip zero columns at row level" semantics
207
+ # by treating only non-zero values as drawn bars. The call
208
+ # site already prunes whole zero columns via
209
+ # skip_data_with_only_zeroes (the column is then absent from
210
+ # grouped_bars for that row's batch — but with batched
211
+ # subplot calls there is no per-row pruning, so we mask
212
+ # value-by-value here).
213
+ has_value[bar_idx, grouped_idx] = sums.loc[period] != 0
214
+
215
+ # Per-category labels
216
+ def _label_for(gb):
217
+ return ' | '.join(str(v) for v in gb) if isinstance(gb, (tuple, list)) else str(gb)
218
+
219
+ cat_labels = [_label_for(gb) for gb in grouped_bars]
220
+
221
+ # Bar geometry. A single bar per slot (n_grouped == 1) is effectively a
222
+ # solo bar: keep it at SOLO_BAR_THICKNESS and do NOT apply thickness_mult.
223
+ # This mirrors _slot_height_for_n_grouped's n==1 branch (slot sized from
224
+ # SOLO_BAR_THICKNESS / slot_floor, unmultiplied) and _plot_simple_bars.
225
+ # thickness_mult only fattens genuinely grouped bars (n_grouped > 1), which
226
+ # pack several value labels into one slot and need the extra pitch; a solo
227
+ # bar gets its label room from the (value-label-aware) slot_floor instead.
228
+ # Fattening the solo bar here would overflow its 0.18" slot (0.1116×2.5 =
229
+ # 0.279") and overlap every neighbouring row.
230
+ if n_grouped == 1:
231
+ bar_w = SOLO_BAR_THICKNESS
232
+ else:
233
+ bar_w = REFERENCE_BAR_THICKNESS * thickness_mult
234
+ step = bar_w * (1 + BAR_GAP_FRACTION)
235
+ total_w = bar_w * n_grouped + bar_w * BAR_GAP_FRACTION * max(0, n_grouped - 1)
236
+
237
+ # y-positions (one per bar)
238
+ if y_positions is None:
239
+ y_pos_vec = np.arange(n_bars, dtype=float)
240
+ else:
241
+ y_pos_vec = np.asarray(y_positions, dtype=float)
242
+
243
+ horizontal = bar_orientation == 'horizontal'
244
+
245
+ # Fix xlim/ylim to the final value-axis range so the layout matches what
246
+ # will be on screen. If the caller supplied explicit bounds (axis_bounds),
247
+ # use them — they override autoscale anyway. We deliberately draw every
248
+ # bar that has data (including sub-pixel-thin ones) so their value labels
249
+ # are emitted; a sub-pixel-width Rectangle renders identically to skipping
250
+ # it, so this does not change the visible bars.
251
+ if value_axis_lim is not None:
252
+ lo, hi = float(value_axis_lim[0]), float(value_axis_lim[1])
253
+ elif values_mat.size:
254
+ lo, hi = _autoscale_value_range(values_mat[has_value])
255
+ else:
256
+ lo, hi = 0.0, 1.0
257
+ if horizontal:
258
+ ax.set_xlim(lo, hi)
259
+ else:
260
+ ax.set_ylim(lo, hi)
261
+
262
+ # Track which categories we've emitted a legend entry for (mirrors original
263
+ # behaviour: each category gets one entry, attached to its first-drawn
264
+ # non-empty draw call across the whole subplot).
265
+ if labeled_groups is None:
266
+ labeled_groups_local: set = set()
267
+ else:
268
+ labeled_groups_local = labeled_groups
269
+
270
+ # Track which categories were drawn (for the invisible-legend fallback)
271
+ drawn_categories: set = set()
272
+
273
+ for grouped_idx in range(n_grouped):
274
+ if horizontal:
275
+ offset = total_w / 2 - bar_w / 2 - grouped_idx * step # top to bottom
276
+ else:
277
+ offset = -total_w / 2 + bar_w / 2 + grouped_idx * step # left to right
278
+ sub_y = y_pos_vec + offset
279
+
280
+ # Draw every bar that has data (has_value already means value != 0).
281
+ # Sub-pixel bars are kept in the container so their value label is
282
+ # still emitted — the label is the whole point of showing that data
283
+ # exists at a category even when the bar is too thin to light up a
284
+ # pixel. A sub-pixel-width Rectangle renders identically to skipping
285
+ # it, so this changes nothing visually except adding the labels.
286
+ # (Previously the draw mask also required ``abs(value) >= threshold``,
287
+ # which dropped sub-pixel bars and their labels together.)
288
+ mask = has_value[:, grouped_idx]
289
+ if not mask.any():
290
+ continue
291
+ ys = sub_y[mask]
292
+ widths = values_mat[mask, grouped_idx]
293
+ color = colors[grouped_idx % len(colors)]
294
+ label_str = cat_labels[grouped_idx]
295
+ if label_str in labeled_groups_local:
296
+ label = ''
297
+ else:
298
+ label = label_str
299
+ labeled_groups_local.add(label_str)
300
+ if horizontal:
301
+ container = ax.barh(ys, widths, height=bar_w, label=label, color=color)
302
+ else:
303
+ container = ax.bar(ys, widths, width=bar_w, label=label, color=color)
304
+ drawn_categories.add(grouped_idx)
305
+ if value_fmt:
306
+ if value_fmt == 'dynamic':
307
+ ax.bar_label(container, fmt=lambda x: format_value_label(x), padding=3)
308
+ else:
309
+ ax.bar_label(container, fmt=lambda x, _s=value_fmt: format(x, _s), padding=3)
310
+
311
+ # Add invisible bars for any category that had no drawn entries, so the
312
+ # legend still lists every category in the configured order.
313
+ for grouped_idx in range(n_grouped):
314
+ if grouped_idx in drawn_categories:
315
+ continue
316
+ label_str = cat_labels[grouped_idx]
317
+ if label_str in labeled_groups_local:
318
+ continue
319
+ labeled_groups_local.add(label_str)
320
+ color = colors[grouped_idx % len(colors)]
321
+ if horizontal:
322
+ ax.barh(0, 0, height=0.01, left=0, label=label_str, color=color)
323
+ else:
324
+ ax.bar(0, 0, width=0.01, bottom=0, label=label_str, color=color)
325
+
326
+
327
+ def _plot_stacked_bars(
328
+ ax,
329
+ df_sub: pd.DataFrame,
330
+ all_bars: list,
331
+ expand_axis_level_names: list,
332
+ stack_level_names: list,
333
+ bar_orientation: str,
334
+ shared_color_map: dict[str, tuple] | None = None,
335
+ y_positions: list[float] | None = None,
336
+ slot_heights: list[float] | None = None,
337
+ value_axis_lim: tuple[float, float] | None = None,
338
+ ) -> None:
339
+ """Render stacked bars onto ax for one subplot.
340
+
341
+ Vectorised: one ax.barh / ax.bar call per stack layer (per sign), using
342
+ numpy cumulative sums to compute the `left`/`bottom` offsets for every
343
+ bar simultaneously. Bars with zero value in the current layer are
344
+ filtered out before the draw call so we don't create invisible
345
+ Rectangles. Positive and negative signs are drawn in separate calls so
346
+ matplotlib's draw order keeps the stacking visually correct.
347
+ """
348
+ # Get stack combinations (for colors and legend)
349
+ if len(stack_level_names) == 1:
350
+ stacks = df_sub.columns.get_level_values(stack_level_names[0]).unique().tolist()
351
+ else:
352
+ stack_df = df_sub.columns.to_frame()[stack_level_names].drop_duplicates()
353
+ stacks = [tuple(row) for row in stack_df.values]
354
+
355
+ # Order stacks by the shared color map's key order (file order from
356
+ # plot_settings.yaml; unlisted labels were appended alphabetically by the
357
+ # plan/builder) so visual stacking matches the legend. Stacks absent from
358
+ # the map (defensive) keep their current relative position at the end.
359
+ if shared_color_map:
360
+ def _stack_key(s):
361
+ return ' | '.join(str(v) for v in s) if isinstance(s, (tuple, list)) else str(s)
362
+ key_pos = {k: i for i, k in enumerate(shared_color_map.keys())}
363
+ orig = list(stacks)
364
+ stacks.sort(
365
+ key=lambda s: (key_pos.get(_stack_key(s), len(key_pos)), orig.index(s)),
366
+ )
367
+ # Geometry note: positives are drawn forward (layer 0 nearest zero).
368
+ # For HORIZONTAL bars that puts file-first at the left (= top in the
369
+ # left->right reading order), matching the stack-area plots, so the
370
+ # forward file order is correct. For VERTICAL bars layer 0 sits at the
371
+ # BOTTOM, so to keep file-first at the TOP of the positive stack
372
+ # (consistent with stack-area + horizontal bars) we feed the stacks in
373
+ # reversed file order. The legend builder below derives its order from
374
+ # the layer indices for the matching orientation, so it stays in sync.
375
+ if bar_orientation != 'horizontal':
376
+ stacks.reverse()
377
+
378
+ # Colors for stacking
379
+ if shared_color_map:
380
+ colors = []
381
+ for s in stacks:
382
+ label = ' | '.join(str(v) for v in s) if isinstance(s, (tuple, list)) else str(s)
383
+ colors.append(shared_color_map.get(label, (0.5, 0.5, 0.5)))
384
+ else:
385
+ n_stack = len(stacks)
386
+ colors = list(plt.colormaps['tab10'].colors[:n_stack])
387
+ if n_stack > 10:
388
+ colors = list(plt.colormaps['tab20'].colors[:n_stack])
389
+
390
+ if not all_bars:
391
+ return
392
+
393
+ n_bars = len(all_bars)
394
+ n_stack = len(stacks)
395
+ # Build a (n_bars, n_stack) value matrix by reusing per-group slices.
396
+ values_mat = np.zeros((n_bars, n_stack), dtype=float)
397
+
398
+ # Cache per-(group, stack) -> Series so each unique slice is computed once.
399
+ group_stack_series: dict = {}
400
+
401
+ def _get_stack_series(df_bar, stack):
402
+ """Return per-period Series for given stack within df_bar (or None)."""
403
+ if isinstance(df_bar, pd.Series):
404
+ return df_bar
405
+ if isinstance(df_bar.columns, pd.MultiIndex):
406
+ try:
407
+ if len(stack_level_names) == 1:
408
+ df_stack = df_bar.xs(stack, level=stack_level_names[0], axis=1)
409
+ else:
410
+ df_stack = df_bar.xs(stack, level=stack_level_names, axis=1)
411
+ except KeyError:
412
+ return None
413
+ else:
414
+ if stack in df_bar.columns:
415
+ df_stack = df_bar[stack]
416
+ else:
417
+ return None
418
+ if isinstance(df_stack, pd.DataFrame):
419
+ df_stack = df_stack.sum(axis=1)
420
+ return df_stack
421
+
422
+ # Cache per-group df slices.
423
+ group_df_cache: dict = {}
424
+
425
+ def _hashable_key(g):
426
+ if isinstance(g, list):
427
+ return tuple(g)
428
+ return g
429
+
430
+ for bar_idx, (group, period) in enumerate(all_bars):
431
+ gkey = _hashable_key(group)
432
+ if gkey not in group_df_cache:
433
+ if group is None:
434
+ df_bar = df_sub
435
+ elif len(expand_axis_level_names) == 1 and isinstance(df_sub.columns, pd.MultiIndex):
436
+ df_bar = df_sub.xs(group, level=expand_axis_level_names[0], axis=1)
437
+ elif len(expand_axis_level_names) == 1:
438
+ df_bar = df_sub[group]
439
+ else:
440
+ df_bar = df_sub.xs(group, level=expand_axis_level_names, axis=1)
441
+ if isinstance(df_bar, pd.Series):
442
+ df_bar = df_bar.to_frame()
443
+ group_df_cache[gkey] = df_bar
444
+ df_bar = group_df_cache[gkey]
445
+
446
+ for stack_idx, stack in enumerate(stacks):
447
+ cache_key = (gkey, stack_idx)
448
+ if cache_key not in group_stack_series:
449
+ group_stack_series[cache_key] = _get_stack_series(df_bar, stack)
450
+ sums = group_stack_series[cache_key]
451
+ if sums is None:
452
+ continue
453
+ if period in sums.index:
454
+ values_mat[bar_idx, stack_idx] = float(sums.loc[period])
455
+
456
+ # Vectorised y-positions
457
+ if y_positions is None:
458
+ y_pos_vec = np.arange(n_bars, dtype=float)
459
+ else:
460
+ y_pos_vec = np.asarray(y_positions, dtype=float)
461
+ bar_h = SOLO_BAR_THICKNESS
462
+
463
+ # Per-layer presence of positive / negative values (for legend ordering)
464
+ pos_stacks = {si for si in range(n_stack) if (values_mat[:, si] > 0).any()}
465
+ neg_stacks = {si for si in range(n_stack) if (values_mat[:, si] < 0).any()}
466
+
467
+ # Positive widths and their running 'left' offsets.
468
+ pos_widths = np.where(values_mat > 0, values_mat, 0.0)
469
+ # left for layer i = sum of widths for layers 0..i-1
470
+ pos_lefts = np.concatenate(
471
+ [np.zeros((n_bars, 1)), np.cumsum(pos_widths, axis=1)[:, :-1]], axis=1,
472
+ ) if n_stack > 0 else np.zeros((n_bars, 0))
473
+
474
+ # Negative widths drawn in reversed stack order so layer N-1 sits closest
475
+ # to zero. For stack_idx s in reversed order, left = sum of widths for
476
+ # layers s+1..N-1 (i.e. layers drawn earlier in the reversed traversal).
477
+ neg_widths = np.where(values_mat < 0, values_mat, 0.0)
478
+ if n_stack > 0:
479
+ # cumulative sum from right -> left, excluding self.
480
+ rev_cum = np.cumsum(neg_widths[:, ::-1], axis=1)[:, ::-1]
481
+ # left for stack s = rev_cum[s+1], with 0 for s = N-1.
482
+ neg_lefts = np.concatenate(
483
+ [rev_cum[:, 1:], np.zeros((n_bars, 1))], axis=1,
484
+ )
485
+ else:
486
+ neg_lefts = np.zeros((n_bars, 0))
487
+
488
+ horizontal = bar_orientation == 'horizontal'
489
+
490
+ # Sub-pixel filter. Use the caller-supplied value-axis range if given
491
+ # (it matches what axis_bounds / always_include_zero produce later);
492
+ # otherwise reproduce matplotlib's autoscale on the per-row stack
493
+ # totals (sum of positives = rightmost edge, sum of negatives =
494
+ # leftmost edge).
495
+ if value_axis_lim is not None:
496
+ lo, hi = float(value_axis_lim[0]), float(value_axis_lim[1])
497
+ elif n_stack > 0 and n_bars > 0:
498
+ row_max = pos_widths.sum(axis=1)
499
+ row_min = neg_widths.sum(axis=1) # already <= 0
500
+ full_max = float(np.nanmax(row_max)) if row_max.size else 0.0
501
+ full_min = float(np.nanmin(row_min)) if row_min.size else 0.0
502
+ lo, hi = _autoscale_value_range(np.array([full_min, full_max]))
503
+ else:
504
+ lo, hi = 0.0, 1.0
505
+ if horizontal:
506
+ ax.set_xlim(lo, hi)
507
+ threshold = _min_visible_data_width(ax, 'x')
508
+ else:
509
+ ax.set_ylim(lo, hi)
510
+ threshold = _min_visible_data_width(ax, 'y')
511
+
512
+ # Dropping sub-pixel layers is safe: pos_lefts/neg_lefts are the
513
+ # cumulative sums computed from the FULL widths matrix (including
514
+ # sub-pixel layers), so the next visible layer's `left`/`bottom` still
515
+ # sits in the geometrically correct place — no gap appears, no shift.
516
+
517
+ # Draw positives, forward order (0..N-1)
518
+ for si in range(n_stack):
519
+ mask = pos_widths[:, si] >= threshold
520
+ if not mask.any():
521
+ continue
522
+ ys = y_pos_vec[mask]
523
+ ws = pos_widths[mask, si]
524
+ ls = pos_lefts[mask, si]
525
+ color = colors[si % len(colors)]
526
+ if horizontal:
527
+ ax.barh(ys, ws, left=ls, height=bar_h, color=color)
528
+ else:
529
+ ax.bar(ys, ws, bottom=ls, width=bar_h, color=color)
530
+
531
+ # Draw negatives, reversed order (N-1..0)
532
+ for si in range(n_stack - 1, -1, -1):
533
+ mask = -neg_widths[:, si] >= threshold
534
+ if not mask.any():
535
+ continue
536
+ ys = y_pos_vec[mask]
537
+ ws = neg_widths[mask, si]
538
+ ls = neg_lefts[mask, si]
539
+ color = colors[si % len(colors)]
540
+ if horizontal:
541
+ ax.barh(ys, ws, left=ls, height=bar_h, color=color)
542
+ else:
543
+ ax.bar(ys, ws, bottom=ls, width=bar_h, color=color)
544
+
545
+ # Build legend handles so visual order matches legend top-to-bottom:
546
+ # - Horizontal: left-to-right = top-to-bottom.
547
+ # Full bar reads: [negatives far-left → 0 → positives far-right]
548
+ # Legend: negative-only first (sorted), then positives (sorted).
549
+ # - Vertical: top-to-bottom = top-to-bottom.
550
+ # Full bar reads: [positives top → 0 → negatives bottom]
551
+ # Legend: positives first (reversed), then negative-only (reversed).
552
+ pos_indices = sorted(pos_stacks)
553
+ neg_only_indices = sorted(neg_stacks - pos_stacks) # stacks that are only negative
554
+
555
+ if bar_orientation == 'horizontal':
556
+ legend_order = neg_only_indices + pos_indices
557
+ else:
558
+ legend_order = list(reversed(pos_indices)) + list(reversed(neg_only_indices))
559
+
560
+ for si in legend_order:
561
+ stack_value = stacks[si]
562
+ label = (
563
+ ' | '.join(str(v) for v in stack_value)
564
+ if isinstance(stack_value, (tuple, list))
565
+ else str(stack_value)
566
+ )
567
+ ax.bar(0, 0, color=colors[si % len(colors)], label=label) # invisible, just for legend
568
+
569
+
570
+ def _plot_simple_bars(
571
+ ax,
572
+ df_sub: pd.DataFrame,
573
+ all_bars: list,
574
+ expand_axis_level_names: list,
575
+ bar_orientation: str,
576
+ value_fmt: str | None,
577
+ y_positions: list[float] | None = None,
578
+ slot_heights: list[float] | None = None,
579
+ value_axis_lim: tuple[float, float] | None = None,
580
+ thickness_mult: float = 1.0,
581
+ shared_color_map: dict[str, tuple] | None = None,
582
+ color_bar_level: str | None = None,
583
+ ) -> None:
584
+ """Render simple bars onto ax for one subplot (no stacking, no grouping).
585
+
586
+ Bars are a single default color unless ``shared_color_map`` and
587
+ ``color_bar_level`` are supplied (from ``color_entity_class`` on the
588
+ plot): then each bar takes the color of its value at ``color_bar_level``
589
+ — an expand-axis level (per-bar color) or the subplot level (whole
590
+ subplot one color). Unresolved values fall back to the default color.
591
+ """
592
+ if not all_bars:
593
+ return
594
+
595
+ # ── Precompute values for every (group, period) in one shot ──
596
+ # Build a dict keyed by group → per-period sum Series (so each unique
597
+ # group is only sliced + .sum()'d once), then read scalars per bar.
598
+ group_sums: dict = {}
599
+ for group, _period in all_bars:
600
+ key = _hashable(group)
601
+ if key in group_sums:
602
+ continue
603
+ if group is None:
604
+ df_bar = df_sub
605
+ elif len(expand_axis_level_names) == 1 and isinstance(df_sub.columns, pd.MultiIndex):
606
+ df_bar = df_sub.xs(group, level=expand_axis_level_names[0], axis=1)
607
+ elif len(expand_axis_level_names) == 1:
608
+ df_bar = df_sub[group]
609
+ else:
610
+ df_bar = df_sub.xs(group, level=expand_axis_level_names, axis=1)
611
+ # Pre-collapse to a per-period scalar (Series indexed by period) so
612
+ # the per-bar lookup below is O(1) instead of a per-bar .sum().
613
+ if isinstance(df_bar, pd.Series):
614
+ sums = df_bar
615
+ else:
616
+ sums = df_bar.sum(axis=1)
617
+ group_sums[key] = sums
618
+
619
+ values: list[float] = []
620
+ for group, period in all_bars:
621
+ sums = group_sums[_hashable(group)]
622
+ values.append(sums.loc[period] if period in sums.index else 0)
623
+
624
+ # y-positions: vector matching all_bars length
625
+ if y_positions is None:
626
+ y_pos_vec = np.arange(len(all_bars), dtype=float)
627
+ else:
628
+ y_pos_vec = np.asarray(y_positions, dtype=float)
629
+
630
+ values_arr = np.asarray(values, dtype=float)
631
+
632
+ # Set xlim/ylim to the final value-axis range (caller-supplied when
633
+ # known, e.g. from axis_bounds) so the layout matches what will be on
634
+ # screen. We draw every bar that has data (value != 0), including
635
+ # sub-pixel-thin ones, so their value labels are still emitted — the
636
+ # label is the whole point of showing that data exists at a category
637
+ # even when the bar is too thin to light up a pixel. A sub-pixel-width
638
+ # Rectangle renders identically to skipping it, so the visible bars are
639
+ # unchanged; only the previously dropped labels are added back.
640
+ horizontal = bar_orientation == 'horizontal'
641
+ if value_axis_lim is not None:
642
+ lo, hi = float(value_axis_lim[0]), float(value_axis_lim[1])
643
+ else:
644
+ lo, hi = _autoscale_value_range(values_arr)
645
+ if horizontal:
646
+ ax.set_xlim(lo, hi)
647
+ else:
648
+ ax.set_ylim(lo, hi)
649
+
650
+ # Per-bar color. The default keeps the historic single-color render
651
+ # (scalar 'steelblue' → byte-identical) unless the plot opted into
652
+ # entity coloring via color_entity_class (shared_color_map +
653
+ # color_bar_level). Color is a property of the bar's expand/subplot
654
+ # value, so it's resolved once per unique group and reused.
655
+ bar_color: object = DEFAULT_SIMPLE_BAR_COLOR
656
+ if shared_color_map and color_bar_level:
657
+ bar_color = [
658
+ _resolve_bar_color(
659
+ group, period, df_sub, expand_axis_level_names,
660
+ color_bar_level, shared_color_map,
661
+ )
662
+ for group, period in all_bars
663
+ ]
664
+
665
+ data_mask = values_arr != 0
666
+ if data_mask.all():
667
+ draw_y = y_pos_vec
668
+ draw_v = values_arr
669
+ draw_color = bar_color
670
+ else:
671
+ draw_y = y_pos_vec[data_mask]
672
+ draw_v = values_arr[data_mask]
673
+ draw_color = (
674
+ [c for c, keep in zip(bar_color, data_mask) if keep]
675
+ if isinstance(bar_color, list) else bar_color
676
+ )
677
+
678
+ # Single vectorised draw call replacing the per-bar loop. Simple bars are
679
+ # one-per-label, so they keep the thin thickness even with value labels —
680
+ # the label room comes from the slot height (VALUE_LABEL_SLOT_FLOOR), not
681
+ # from fattening the bar. thickness_mult only fattens grouped bars.
682
+ bar_h = SOLO_BAR_THICKNESS
683
+ if horizontal:
684
+ container = ax.barh(draw_y, draw_v, height=bar_h, color=draw_color)
685
+ else: # vertical
686
+ container = ax.bar(draw_y, draw_v, width=bar_h, color=draw_color)
687
+ if value_fmt:
688
+ if value_fmt == 'dynamic':
689
+ ax.bar_label(container, fmt=lambda x: format_value_label(x), padding=3)
690
+ else:
691
+ ax.bar_label(container, fmt=lambda x, _s=value_fmt: format(x, _s), padding=3)
692
+
693
+
694
+ def _resolve_bar_color(
695
+ group,
696
+ period,
697
+ df_sub: pd.DataFrame,
698
+ expand_axis_level_names: list,
699
+ color_bar_level: str,
700
+ shared_color_map: dict[str, tuple],
701
+ default=DEFAULT_SIMPLE_BAR_COLOR,
702
+ ):
703
+ """Color for one simple bar, keyed by its ``color_bar_level`` value.
704
+
705
+ The color level can sit in three places:
706
+ * an **expand-axis** column level → the value is the ``group`` (or its
707
+ component when several dims expand together) — per-bar color;
708
+ * the **row index** → the bar itself IS the entity (a ``b``-role dim
709
+ moved to the index, e.g. the sum/total variant), so the value is the
710
+ bar's index value ``period``;
711
+ * a **subplot / single-valued** column level → constant across
712
+ ``df_sub``, so the whole subplot is one color.
713
+ Falls back to ``default`` when the value isn't in the color map (e.g. an
714
+ entity the template doesn't list).
715
+ """
716
+ key = None
717
+ if color_bar_level in expand_axis_level_names:
718
+ if len(expand_axis_level_names) == 1:
719
+ key = group
720
+ elif isinstance(group, (tuple, list)):
721
+ pos = expand_axis_level_names.index(color_bar_level)
722
+ if pos < len(group):
723
+ key = group[pos]
724
+ else:
725
+ idx_multi = isinstance(df_sub.index, pd.MultiIndex)
726
+ idx_names = list(df_sub.index.names) if idx_multi else [df_sub.index.name]
727
+ if color_bar_level in idx_names:
728
+ if idx_multi and isinstance(period, (tuple, list)):
729
+ pos = idx_names.index(color_bar_level)
730
+ if pos < len(period):
731
+ key = period[pos]
732
+ else:
733
+ key = period
734
+ else:
735
+ col_multi = isinstance(df_sub.columns, pd.MultiIndex)
736
+ col_names = list(df_sub.columns.names) if col_multi else [df_sub.columns.name]
737
+ if color_bar_level in col_names:
738
+ vals = (
739
+ df_sub.columns.get_level_values(color_bar_level).unique()
740
+ if col_multi else df_sub.columns.unique()
741
+ )
742
+ if len(vals):
743
+ key = vals[0]
744
+ if key is None:
745
+ return default
746
+ return shared_color_map.get(str(key), default)
747
+
748
+
749
+ def _hashable(value):
750
+ """Return a hashable key for grouping by expand_axis value (which may be a list)."""
751
+ if isinstance(value, list):
752
+ return tuple(value)
753
+ return value