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.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- 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
|