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