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