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,171 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
# Dimension rule characters come from templates/default_plots.yaml and must remain unchanged.
|
|
4
|
+
# This dict documents their meaning for readability in code (e.g. DIMENSION_RULES['t'] → 'time axis (x)').
|
|
5
|
+
DIMENSION_RULES: dict[str, str] = {
|
|
6
|
+
't': 'time axis (x, datetime)',
|
|
7
|
+
'i': 'time-like axis (x, chunk-average)',
|
|
8
|
+
'b': 'bar/line column stack',
|
|
9
|
+
'e': 'expand (separate y-axes)',
|
|
10
|
+
'g': 'group (grouped bars)',
|
|
11
|
+
's': 'stack (stacked segments)',
|
|
12
|
+
'u': 'subplot (separate plot panel)',
|
|
13
|
+
'l': 'line series',
|
|
14
|
+
'f': 'file (separate file per member)',
|
|
15
|
+
'm': 'merge (sum and collapse)',
|
|
16
|
+
'a': 'average and collapse',
|
|
17
|
+
'y': 'weight by years_represented, then sum and collapse (period row only)',
|
|
18
|
+
'z': 'years_represented weighted average and collapse (period row only)',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Field names for plot settings — used by _is_single_config() to detect config dicts.
|
|
22
|
+
PLOT_FIELD_NAMES = {
|
|
23
|
+
'plot_name', 'map_dimensions_for_plots', 'subplots_per_row', 'legend',
|
|
24
|
+
'bar_orientation', 'base_length', 'max_subplots_per_file', 'max_items_per_plot',
|
|
25
|
+
'max_items_per_subplot_column',
|
|
26
|
+
'time_average_duration', 'xlabel', 'ylabel', 'value_label', 'axis_bounds',
|
|
27
|
+
'axis_scale_min_max', # backward compat alias for axis_bounds
|
|
28
|
+
'axis_tick_format', 'always_include_zero_in_axis', 'skip_data_with_only_zeroes',
|
|
29
|
+
'multiply_by', 'full_timeline', 'subplots_by_magnitudes',
|
|
30
|
+
'variant', # used by plot_config_reader to override variant letter derivation
|
|
31
|
+
'color_category', 'color_entity_class', # color-template hints (chunk D)
|
|
32
|
+
'scenario_rule', 'comparison_overrides', # comparison-mode add-ons
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PlotConfig:
|
|
38
|
+
"""Typed, default-carrying wrapper for a single plot's settings dict.
|
|
39
|
+
|
|
40
|
+
Callers that pass raw dicts (e.g. scenario_results.py) need no change:
|
|
41
|
+
``PlotConfig(**raw_dict)`` works as long as the dict only contains known fields.
|
|
42
|
+
"""
|
|
43
|
+
plot_name: str = ''
|
|
44
|
+
map_dimensions_for_plots: dict = field(default_factory=dict)
|
|
45
|
+
subplots_per_row: int = 2
|
|
46
|
+
legend: str = 'right' # 'right', 'all', or 'shared'
|
|
47
|
+
bar_orientation: str = 'horizontal'
|
|
48
|
+
base_length: float = 4.0
|
|
49
|
+
max_subplots_per_file: int = 6
|
|
50
|
+
max_items_per_plot: int = 10
|
|
51
|
+
max_items_per_subplot_column: int = 40
|
|
52
|
+
time_average_duration: str | None = None
|
|
53
|
+
xlabel: str | None = None
|
|
54
|
+
ylabel: str | None = None
|
|
55
|
+
value_label: str | None = None
|
|
56
|
+
axis_bounds: dict | list | str | None = None
|
|
57
|
+
axis_tick_format: str = 'dynamic'
|
|
58
|
+
always_include_zero_in_axis: bool = True
|
|
59
|
+
skip_data_with_only_zeroes: bool = False
|
|
60
|
+
multiply_by: float | None = None
|
|
61
|
+
full_timeline: bool = False
|
|
62
|
+
subplots_by_magnitudes: bool = False
|
|
63
|
+
# Hints for the color-template resolver (see flextool.plot_outputs.color_template).
|
|
64
|
+
# Default None means "no hint" — fall back to the tab10/tab20 palette as today.
|
|
65
|
+
color_category: str | None = None
|
|
66
|
+
color_entity_class: str | None = None
|
|
67
|
+
|
|
68
|
+
# --- Comparison-mode add-ons ---
|
|
69
|
+
# The unioned plan parquet at comparison-view time has the post-single-rules
|
|
70
|
+
# shape with `scenario` prepended as the outermost column-MultiIndex level.
|
|
71
|
+
# `scenario_rule` is the dim-rule character applied to that scenario level
|
|
72
|
+
# when building the comparison PlotPlan (see
|
|
73
|
+
# flextool.scenario_comparison.plan_union.compute_comparison_plan_from_single).
|
|
74
|
+
# Common choices: 'g' (grouped/coloured bars), 'l' (line series), 'u'
|
|
75
|
+
# (subplot per scenario), 'f' (file per scenario), 's' (stack — rare).
|
|
76
|
+
# When None, the config has no comparison rendering — comparison view
|
|
77
|
+
# surfaces "no comparison rule defined" for this plot.
|
|
78
|
+
scenario_rule: str | None = None
|
|
79
|
+
# Optional per-mode overrides applied on top of the single-mode values when
|
|
80
|
+
# rendering the comparison view (e.g. fewer ``max_subplots_per_file``
|
|
81
|
+
# because each subplot now has scenario-many more bars). Any field listed
|
|
82
|
+
# in PLOT_FIELD_NAMES is allowed. Plain dict so the YAML stays terse.
|
|
83
|
+
comparison_overrides: dict | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def default_ylabel_for(
|
|
87
|
+
result_key: str, variant_letter: str | None = None
|
|
88
|
+
) -> str | None:
|
|
89
|
+
"""Derive a default y-axis label (the displayed unit) for a plot.
|
|
90
|
+
|
|
91
|
+
Single source: the output-metadata catalog
|
|
92
|
+
(:func:`flextool.process_outputs._output_meta.result_key_summary`).
|
|
93
|
+
Returns the unit string when one is declared and non-trivial, else
|
|
94
|
+
``None`` so callers leave an explicit ylabel untouched. Imported lazily
|
|
95
|
+
to keep ``plot_outputs`` free of a ``process_outputs`` import at module
|
|
96
|
+
load.
|
|
97
|
+
|
|
98
|
+
When *variant_letter* is given, the variant-adjusted unit is used
|
|
99
|
+
(:func:`~flextool.process_outputs._output_meta.result_variant_summary`)
|
|
100
|
+
so the ``a`` (total → ``/a`` stripped) and ``w`` (weekly, unchanged)
|
|
101
|
+
plot variants get the correct value-axis unit; otherwise the base
|
|
102
|
+
output unit is used unchanged.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
if variant_letter is None:
|
|
106
|
+
from flextool.process_outputs._output_meta import result_key_summary
|
|
107
|
+
summary = result_key_summary(result_key)
|
|
108
|
+
else:
|
|
109
|
+
from flextool.process_outputs._output_meta import (
|
|
110
|
+
result_variant_summary,
|
|
111
|
+
)
|
|
112
|
+
summary = result_variant_summary(result_key, variant_letter)
|
|
113
|
+
except Exception:
|
|
114
|
+
return None
|
|
115
|
+
if not summary:
|
|
116
|
+
return None
|
|
117
|
+
unit = summary[0]
|
|
118
|
+
if not unit or unit == 'ratio':
|
|
119
|
+
return None
|
|
120
|
+
return unit
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_single_config(d: dict) -> bool:
|
|
124
|
+
"""Return True if *d* is a single-plot config (has known field names), not a named-config dict."""
|
|
125
|
+
return any(k in PLOT_FIELD_NAMES for k in d)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_new_format_entry(d: dict) -> bool:
|
|
129
|
+
"""Return True if *d* is an entry-name-based config (has 'group' and 'order' keys)."""
|
|
130
|
+
return 'group' in d and 'order' in d
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def flatten_new_format(plots: dict) -> dict:
|
|
134
|
+
"""Convert entry-name-based format to flat result_key-based format.
|
|
135
|
+
|
|
136
|
+
Entries have ``group`` and ``order`` keys; result_keys are nested
|
|
137
|
+
underneath. The flattened output maps each result_key directly to
|
|
138
|
+
its sub-config dict (same shape the orchestrator/plan code expects).
|
|
139
|
+
|
|
140
|
+
When the same result_key appears under multiple entry names (e.g.
|
|
141
|
+
``costs_dt_p`` under both "Costs" and "Costs lines"), the sub-configs
|
|
142
|
+
from each entry are merged into a single dict for that result_key.
|
|
143
|
+
|
|
144
|
+
Sub-configs that lack an explicit ``plot_name`` get the entry name
|
|
145
|
+
(e.g. "Node flows") injected as ``_entry_name`` so the orchestrator
|
|
146
|
+
can use it as a filename fallback instead of the raw result_key.
|
|
147
|
+
|
|
148
|
+
Already-flat entries (result_key → config dict without group/order)
|
|
149
|
+
pass through unchanged for programmatic callers.
|
|
150
|
+
"""
|
|
151
|
+
flat: dict = {}
|
|
152
|
+
for key, value in plots.items():
|
|
153
|
+
if not isinstance(value, dict):
|
|
154
|
+
continue
|
|
155
|
+
if _is_new_format_entry(value):
|
|
156
|
+
for rk, rk_val in value.items():
|
|
157
|
+
if rk in ('group', 'order') or not isinstance(rk_val, dict):
|
|
158
|
+
continue
|
|
159
|
+
# Inject entry name into sub-configs that lack plot_name
|
|
160
|
+
enriched = dict(rk_val)
|
|
161
|
+
for setting_name, setting in enriched.items():
|
|
162
|
+
if isinstance(setting, dict) and 'plot_name' not in setting:
|
|
163
|
+
setting['_entry_name'] = key
|
|
164
|
+
if rk in flat:
|
|
165
|
+
flat[rk].update(enriched)
|
|
166
|
+
else:
|
|
167
|
+
flat[rk] = enriched
|
|
168
|
+
else:
|
|
169
|
+
# Already flat (result_key → config dict) — pass through
|
|
170
|
+
flat[key] = value
|
|
171
|
+
return flat
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Value formatting and filename utilities for plot_outputs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from decimal import Decimal, InvalidOperation
|
|
11
|
+
from matplotlib.ticker import Formatter, FuncFormatter
|
|
12
|
+
|
|
13
|
+
from flextool.lean_parquet import read_lean_parquet
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── superscript digits for nice exponent display (single source) ──
|
|
17
|
+
_SUPERSCRIPTS = str.maketrans('0123456789-', '⁰¹²³⁴⁵⁶⁷⁸⁹⁻')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sig_figs_fmt(x, pos, n: int = 5) -> str:
|
|
21
|
+
"""Format x with n significant figures, plain notation, no trailing zeros."""
|
|
22
|
+
if x == 0:
|
|
23
|
+
return '0'
|
|
24
|
+
try:
|
|
25
|
+
d = Decimal(str(x))
|
|
26
|
+
rounded = d.quantize(Decimal(10) ** (d.adjusted() - n + 1))
|
|
27
|
+
result = f'{rounded:f}'
|
|
28
|
+
if '.' in result:
|
|
29
|
+
result = result.rstrip('0').rstrip('.')
|
|
30
|
+
return result
|
|
31
|
+
except (InvalidOperation, ValueError):
|
|
32
|
+
return str(x)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DynamicFormatter(Formatter):
|
|
36
|
+
"""Value-axis formatter that adapts to the data range.
|
|
37
|
+
|
|
38
|
+
Determines precision from the maximum tick value (2 significant digits):
|
|
39
|
+
- max >= 1,000: commas, no decimals (e.g. "1,200,000")
|
|
40
|
+
- max >= 10: no decimals (e.g. "45")
|
|
41
|
+
- max >= 1: 1 decimal (e.g. "4.7")
|
|
42
|
+
- max >= 0.1: 2 decimals (e.g. "0.46")
|
|
43
|
+
- smaller: more decimals as needed
|
|
44
|
+
|
|
45
|
+
Switches to engineering notation (exponent multiple of 3) when
|
|
46
|
+
|max value| > 990,000,000 (e.g. "1.5e9").
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# ── superscript digits for nice exponent display ──
|
|
50
|
+
# Alias to the module-level single source so tick formatting and the
|
|
51
|
+
# value-label formatter share one table.
|
|
52
|
+
_SUPERSCRIPTS = _SUPERSCRIPTS
|
|
53
|
+
|
|
54
|
+
def format_ticks(self, values):
|
|
55
|
+
if len(values) == 0:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
finite = [v for v in values if math.isfinite(v)]
|
|
59
|
+
if not finite:
|
|
60
|
+
return [str(v) for v in values]
|
|
61
|
+
|
|
62
|
+
max_abs = max(abs(v) for v in finite)
|
|
63
|
+
if max_abs == 0:
|
|
64
|
+
return ['0' for _ in values]
|
|
65
|
+
|
|
66
|
+
# Engineering notation for very large values
|
|
67
|
+
if max_abs > 990_000_000:
|
|
68
|
+
return self._eng_format(values, max_abs)
|
|
69
|
+
|
|
70
|
+
# Number of decimals: show ~2 significant digits
|
|
71
|
+
# decimals = max(0, 1 - floor(log10(max_abs)))
|
|
72
|
+
log_max = math.floor(math.log10(max_abs))
|
|
73
|
+
decimals = max(0, 1 - log_max)
|
|
74
|
+
|
|
75
|
+
# Commas for thousands and above
|
|
76
|
+
if max_abs >= 1_000:
|
|
77
|
+
fmt_spec = f',.{decimals}f'
|
|
78
|
+
else:
|
|
79
|
+
fmt_spec = f'.{decimals}f'
|
|
80
|
+
|
|
81
|
+
return [format(v, fmt_spec) for v in values]
|
|
82
|
+
|
|
83
|
+
def _eng_format(self, values, max_abs):
|
|
84
|
+
"""Format with engineering notation (exponent as multiple of 3)."""
|
|
85
|
+
exp = 3 * (math.floor(math.log10(max_abs)) // 3)
|
|
86
|
+
divisor = 10 ** exp
|
|
87
|
+
|
|
88
|
+
# Determine decimals for the divided values (2 sig digits)
|
|
89
|
+
max_divided = max_abs / divisor
|
|
90
|
+
log_div = math.floor(math.log10(max_divided)) if max_divided > 0 else 0
|
|
91
|
+
decimals = max(0, 1 - log_div)
|
|
92
|
+
|
|
93
|
+
fmt_spec = f',.{decimals}f' if max_divided >= 1_000 else f'.{decimals}f'
|
|
94
|
+
exp_str = str(exp).translate(self._SUPERSCRIPTS)
|
|
95
|
+
|
|
96
|
+
result = []
|
|
97
|
+
for v in values:
|
|
98
|
+
if not math.isfinite(v):
|
|
99
|
+
result.append(str(v))
|
|
100
|
+
elif v == 0:
|
|
101
|
+
result.append('0')
|
|
102
|
+
else:
|
|
103
|
+
divided = v / divisor
|
|
104
|
+
formatted = format(divided, fmt_spec)
|
|
105
|
+
result.append(f'{formatted}×10{exp_str}')
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
def __call__(self, x, pos=None):
|
|
109
|
+
"""Fallback for individual tick formatting (cursor display, width estimates)."""
|
|
110
|
+
if not math.isfinite(x):
|
|
111
|
+
return str(x)
|
|
112
|
+
if x == 0:
|
|
113
|
+
return '0'
|
|
114
|
+
max_abs = abs(x)
|
|
115
|
+
if max_abs > 990_000_000:
|
|
116
|
+
exp = 3 * (math.floor(math.log10(max_abs)) // 3)
|
|
117
|
+
divisor = 10 ** exp
|
|
118
|
+
divided = x / divisor
|
|
119
|
+
log_div = math.floor(math.log10(abs(divided))) if divided != 0 else 0
|
|
120
|
+
dec = max(0, 1 - log_div)
|
|
121
|
+
exp_str = str(exp).translate(self._SUPERSCRIPTS)
|
|
122
|
+
return f'{format(divided, f".{dec}f")}×10{exp_str}'
|
|
123
|
+
log_max = math.floor(math.log10(max_abs))
|
|
124
|
+
decimals = max(0, 1 - log_max)
|
|
125
|
+
if max_abs >= 1_000:
|
|
126
|
+
return format(x, f',.{decimals}f')
|
|
127
|
+
return format(x, f'.{decimals}f')
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Module-level instance reused only to delegate the small-value (< 1e6)
|
|
131
|
+
# comma-formatting path of format_value_label, so small value labels render
|
|
132
|
+
# exactly as the dynamic tick path would. This does NOT affect tick labels.
|
|
133
|
+
_VALUE_LABEL_DELEGATE = DynamicFormatter()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def format_value_label(x) -> str:
|
|
137
|
+
"""Format a bar value label (the ``'dynamic'`` value-label path only).
|
|
138
|
+
|
|
139
|
+
For ``abs(x) >= 1_000_000`` produce compact engineering notation in the
|
|
140
|
+
×10⁶ / ×10⁹ / ×10¹² style with 5 significant figures on the mantissa
|
|
141
|
+
(trailing zeros and a trailing '.' stripped), e.g. ``16_701_871`` →
|
|
142
|
+
``"16.701×10⁶"``. Below 1e6 (and for non-finite / zero values) delegate
|
|
143
|
+
to the existing dynamic comma formatting so small labels are unchanged.
|
|
144
|
+
|
|
145
|
+
Note: this is for *value labels on bars only*. Axis tick labels keep going
|
|
146
|
+
through DynamicFormatter / _get_value_formatter and must not call this.
|
|
147
|
+
"""
|
|
148
|
+
if not math.isfinite(x) or x == 0:
|
|
149
|
+
return _VALUE_LABEL_DELEGATE(x, None)
|
|
150
|
+
abs_x = abs(x)
|
|
151
|
+
if abs_x < 1_000_000:
|
|
152
|
+
return _VALUE_LABEL_DELEGATE(x, None)
|
|
153
|
+
|
|
154
|
+
# Engineering exponent (multiple of 3); for abs_x >= 1e6 this is >= 6.
|
|
155
|
+
exp = 3 * (math.floor(math.log10(abs_x)) // 3)
|
|
156
|
+
mantissa = x / 10 ** exp
|
|
157
|
+
# 5 significant figures: |mantissa| lies in [1, 1000).
|
|
158
|
+
decimals = max(0, 4 - math.floor(math.log10(abs(mantissa))))
|
|
159
|
+
mantissa_str = format(mantissa, f'.{decimals}f')
|
|
160
|
+
if '.' in mantissa_str:
|
|
161
|
+
mantissa_str = mantissa_str.rstrip('0').rstrip('.')
|
|
162
|
+
exp_str = str(exp).translate(_SUPERSCRIPTS)
|
|
163
|
+
return f'{mantissa_str}×10{exp_str}'
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _get_value_formatter(axis_tick_format, idx: int):
|
|
167
|
+
"""Return a tick formatter for subplot idx.
|
|
168
|
+
|
|
169
|
+
axis_tick_format can be:
|
|
170
|
+
None → sig-figs FuncFormatter (default, 5 sig figs, plain notation)
|
|
171
|
+
'dynamic' → DynamicFormatter (adapts to data range)
|
|
172
|
+
',.0f' → StrMethodFormatter applied to all subplots
|
|
173
|
+
[',.0f', '.2%'] → per-subplot StrMethodFormatter; sig-figs default beyond list length
|
|
174
|
+
The format spec is a standard Python format spec (without braces), e.g. ',.0f', '.2%'.
|
|
175
|
+
"""
|
|
176
|
+
if axis_tick_format is None:
|
|
177
|
+
return FuncFormatter(_sig_figs_fmt)
|
|
178
|
+
if isinstance(axis_tick_format, str):
|
|
179
|
+
spec = axis_tick_format
|
|
180
|
+
elif isinstance(axis_tick_format, list):
|
|
181
|
+
entry = axis_tick_format[idx] if idx < len(axis_tick_format) else None
|
|
182
|
+
if entry is None:
|
|
183
|
+
return FuncFormatter(_sig_figs_fmt)
|
|
184
|
+
spec = entry
|
|
185
|
+
else:
|
|
186
|
+
return FuncFormatter(_sig_figs_fmt)
|
|
187
|
+
if spec == 'dynamic':
|
|
188
|
+
return DynamicFormatter()
|
|
189
|
+
def _fmt_with_spec(x, pos, _spec=str(spec)):
|
|
190
|
+
try:
|
|
191
|
+
return format(x, _spec)
|
|
192
|
+
except (ValueError, TypeError) as e:
|
|
193
|
+
logging.error(f"axis_tick_format: cannot format value {x!r} with spec {_spec!r}: {e}")
|
|
194
|
+
return str(x)
|
|
195
|
+
return FuncFormatter(_fmt_with_spec)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def generate_split_filename(
|
|
199
|
+
base_name: str,
|
|
200
|
+
plot_dir: str,
|
|
201
|
+
extension: str,
|
|
202
|
+
file_idx: int | None = None,
|
|
203
|
+
needs_split: bool = False,
|
|
204
|
+
file_member: str | None = None,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Generate filename with appropriate suffix based on splitting needs.
|
|
207
|
+
|
|
208
|
+
- No splitting: base_name.extension
|
|
209
|
+
- With file_member only: base_name_member.extension
|
|
210
|
+
- With splitting: base_name_01.extension, base_name_02.extension, ...
|
|
211
|
+
- With both: base_name_member_01.extension, base_name_member_02.extension, ...
|
|
212
|
+
|
|
213
|
+
File index uses leading zeros for numbers < 10 (e.g., _01, _02, ..., _09, _10).
|
|
214
|
+
"""
|
|
215
|
+
name = base_name
|
|
216
|
+
if file_member is not None:
|
|
217
|
+
name = f'{name}_{file_member}'
|
|
218
|
+
if not needs_split:
|
|
219
|
+
return f'{plot_dir}/{name}.{extension}'
|
|
220
|
+
else:
|
|
221
|
+
idx_str = f'{file_idx:02d}'
|
|
222
|
+
return f'{plot_dir}/{name}_{idx_str}.{extension}'
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def split_into_chunks(items, chunk_size):
|
|
226
|
+
"""Split a list into chunks of specified size."""
|
|
227
|
+
for i in range(0, len(items), chunk_size):
|
|
228
|
+
yield items[i:i + chunk_size]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _chunk_average_df(df: pd.DataFrame, chunk_size: int) -> pd.DataFrame:
|
|
232
|
+
"""Chunk-average a DataFrame along its (simple) index.
|
|
233
|
+
|
|
234
|
+
Divides the index into consecutive blocks of `chunk_size` rows,
|
|
235
|
+
averages each block, and labels the result with the first original
|
|
236
|
+
index label of each chunk.
|
|
237
|
+
"""
|
|
238
|
+
chunk_ids = np.arange(len(df)) // chunk_size
|
|
239
|
+
first_labels = df.index[::chunk_size]
|
|
240
|
+
averaged = df.groupby(chunk_ids).mean()
|
|
241
|
+
averaged.index = first_labels[:len(averaged)]
|
|
242
|
+
averaged.index.name = df.index.name
|
|
243
|
+
return averaged
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# Timeline breaks
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def load_timeline_breaks(*parquet_dirs: str | os.PathLike) -> set[str]:
|
|
251
|
+
"""Load timeline break timesteps from one or more parquet directories.
|
|
252
|
+
|
|
253
|
+
Reads ``timeline_breaks.parquet`` from each directory and returns the
|
|
254
|
+
union of all break timestep identifiers (e.g. ``{'t1336', 't2848'}``).
|
|
255
|
+
Returns an empty set if no files are found.
|
|
256
|
+
"""
|
|
257
|
+
break_times: set[str] = set()
|
|
258
|
+
for d in parquet_dirs:
|
|
259
|
+
path = os.path.join(str(d), 'timeline_breaks.parquet')
|
|
260
|
+
if os.path.exists(path):
|
|
261
|
+
try:
|
|
262
|
+
df = read_lean_parquet(path)
|
|
263
|
+
if 'time' in df.columns:
|
|
264
|
+
break_times.update(df['time'].astype(str))
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
return break_times
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def split_at_timeline_breaks(
|
|
271
|
+
df: pd.DataFrame,
|
|
272
|
+
break_times: set[str],
|
|
273
|
+
) -> list[pd.DataFrame]:
|
|
274
|
+
"""Split a DataFrame into contiguous segments at timeline discontinuities.
|
|
275
|
+
|
|
276
|
+
Returns a list of DataFrames, one per contiguous block. Plotting each
|
|
277
|
+
segment separately produces clean gaps (lines/areas stop and restart)
|
|
278
|
+
rather than plunging to zero at NaN values.
|
|
279
|
+
|
|
280
|
+
Works with both simple and MultiIndex row indices.
|
|
281
|
+
"""
|
|
282
|
+
if not break_times or df.empty:
|
|
283
|
+
return [df]
|
|
284
|
+
|
|
285
|
+
# Find the time level in the index
|
|
286
|
+
if isinstance(df.index, pd.MultiIndex):
|
|
287
|
+
time_level = df.index.nlevels - 1 # default: last level
|
|
288
|
+
for i, name in enumerate(df.index.names):
|
|
289
|
+
if name and str(name).lower() in ('time', 't'):
|
|
290
|
+
time_level = i
|
|
291
|
+
break
|
|
292
|
+
time_vals = df.index.get_level_values(time_level).astype(str)
|
|
293
|
+
else:
|
|
294
|
+
time_vals = df.index.astype(str)
|
|
295
|
+
|
|
296
|
+
# Find integer positions where breaks occur
|
|
297
|
+
break_positions = [i for i, t in enumerate(time_vals) if t in break_times]
|
|
298
|
+
if not break_positions:
|
|
299
|
+
return [df]
|
|
300
|
+
|
|
301
|
+
# Split into contiguous segments
|
|
302
|
+
segments: list[pd.DataFrame] = []
|
|
303
|
+
prev = 0
|
|
304
|
+
for pos in break_positions:
|
|
305
|
+
if prev < pos:
|
|
306
|
+
segments.append(df.iloc[prev:pos])
|
|
307
|
+
prev = pos
|
|
308
|
+
if prev < len(df):
|
|
309
|
+
segments.append(df.iloc[prev:])
|
|
310
|
+
|
|
311
|
+
return segments
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def insert_timeline_breaks(
|
|
315
|
+
df: pd.DataFrame,
|
|
316
|
+
break_times: set[str],
|
|
317
|
+
gap_rows: int = 3,
|
|
318
|
+
) -> pd.DataFrame:
|
|
319
|
+
"""Insert NaN rows before timeline discontinuities for visual gaps.
|
|
320
|
+
|
|
321
|
+
Inserts *gap_rows* NaN rows (default 3) between contiguous blocks
|
|
322
|
+
so the gap is clearly visible in plots.
|
|
323
|
+
"""
|
|
324
|
+
if not break_times or df.empty:
|
|
325
|
+
return df
|
|
326
|
+
|
|
327
|
+
segments = split_at_timeline_breaks(df, break_times)
|
|
328
|
+
if len(segments) <= 1:
|
|
329
|
+
return df
|
|
330
|
+
|
|
331
|
+
# Insert NaN rows between segments
|
|
332
|
+
parts: list[pd.DataFrame] = []
|
|
333
|
+
for i, seg in enumerate(segments):
|
|
334
|
+
if i > 0:
|
|
335
|
+
prev_seg = parts[-1]
|
|
336
|
+
gap_index = prev_seg.index[-1:].repeat(gap_rows)
|
|
337
|
+
nan_block = pd.DataFrame(
|
|
338
|
+
np.nan,
|
|
339
|
+
index=gap_index,
|
|
340
|
+
columns=df.columns,
|
|
341
|
+
)
|
|
342
|
+
parts.append(nan_block)
|
|
343
|
+
parts.append(seg)
|
|
344
|
+
|
|
345
|
+
return pd.concat(parts)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Legend sizing, positioning, and label formatting utilities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def estimate_legend_width(labels, title: str = '', base_width: float = 1.5) -> float:
|
|
8
|
+
"""Estimate the width in inches needed for a legend based on label content.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
labels: List of label strings
|
|
12
|
+
title: Legend title string
|
|
13
|
+
base_width: Minimum width in inches (default: 1.5)
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Estimated width in inches
|
|
17
|
+
"""
|
|
18
|
+
if not labels:
|
|
19
|
+
return base_width
|
|
20
|
+
|
|
21
|
+
max_label_len = max(len(str(label)) for label in labels)
|
|
22
|
+
title_len = len(str(title)) if title else 0
|
|
23
|
+
|
|
24
|
+
# ~0.09 inches per character + base padding (accounts for typical 8-10pt font)
|
|
25
|
+
char_width = 0.07
|
|
26
|
+
label_width = max_label_len * char_width
|
|
27
|
+
title_width = title_len * char_width
|
|
28
|
+
|
|
29
|
+
estimated_width = max(label_width, title_width, base_width) + 0.8
|
|
30
|
+
return estimated_width
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def estimate_legend_height(n_entries: int, has_title: bool = True) -> float:
|
|
34
|
+
"""Estimate the height in inches needed for a legend.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
n_entries: Number of legend entries
|
|
38
|
+
has_title: Whether the legend has a title
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Estimated height in inches
|
|
42
|
+
"""
|
|
43
|
+
entry_height = 0.22 # approximate height per entry at 10pt font
|
|
44
|
+
title_height = 0.25 if has_title else 0.0
|
|
45
|
+
padding = 0.15 # top + bottom padding
|
|
46
|
+
return n_entries * entry_height + title_height + padding
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _format_legend_labels(items: list) -> list[str]:
|
|
50
|
+
"""Convert legend items (possibly tuples/lists) to display strings.
|
|
51
|
+
|
|
52
|
+
Tuples and lists are joined with ' | '; all other items are str()-converted.
|
|
53
|
+
"""
|
|
54
|
+
result = []
|
|
55
|
+
for item in items:
|
|
56
|
+
if isinstance(item, (tuple, list)):
|
|
57
|
+
result.append(' | '.join(str(v) for v in item))
|
|
58
|
+
else:
|
|
59
|
+
result.append(str(item))
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _should_show_legend(
|
|
64
|
+
legend_position: str,
|
|
65
|
+
sub_levels: list,
|
|
66
|
+
idx: int,
|
|
67
|
+
n_cols: int,
|
|
68
|
+
n_subs: int,
|
|
69
|
+
) -> bool:
|
|
70
|
+
"""Return True if the legend should be shown on subplot idx.
|
|
71
|
+
|
|
72
|
+
'all' → show on every subplot.
|
|
73
|
+
'right' → show only on the rightmost column or the last subplot.
|
|
74
|
+
'shared' → never (the caller renders one figure-level legend separately).
|
|
75
|
+
"""
|
|
76
|
+
if legend_position == 'shared':
|
|
77
|
+
return False
|
|
78
|
+
if legend_position == 'all':
|
|
79
|
+
return True
|
|
80
|
+
if legend_position == 'right':
|
|
81
|
+
col = idx % n_cols
|
|
82
|
+
if col == n_cols - 1 or idx == n_subs - 1:
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_shared_color_map(
|
|
88
|
+
labels: list[str],
|
|
89
|
+
*,
|
|
90
|
+
color_template: dict | None = None,
|
|
91
|
+
category: str | None = None,
|
|
92
|
+
entity_class: str | None = None,
|
|
93
|
+
scenario: bool = False,
|
|
94
|
+
) -> dict[str, tuple]:
|
|
95
|
+
"""Assign consistent colors to a list of labels.
|
|
96
|
+
|
|
97
|
+
Template-matched labels (via ``color_template`` + ``category`` /
|
|
98
|
+
``entity_class``) get their explicit color and do **not** consume a
|
|
99
|
+
palette slot. Remaining labels get tab10/tab20 colors assigned in
|
|
100
|
+
input order, so e.g. the first un-templated label always gets
|
|
101
|
+
``tab10[0]`` regardless of how many template matches preceded it.
|
|
102
|
+
|
|
103
|
+
With no template (the default), this is byte-identical to the
|
|
104
|
+
previous palette-only behaviour: the palette is picked based on the
|
|
105
|
+
total label count — tab10 when ≤10, tab20 otherwise — and labels are
|
|
106
|
+
assigned in input order, cycling if the palette is exhausted.
|
|
107
|
+
"""
|
|
108
|
+
from flextool.plot_outputs.color_template import resolve_label_color
|
|
109
|
+
|
|
110
|
+
n = len(labels)
|
|
111
|
+
|
|
112
|
+
# First pass: resolve template colors for each label. None means
|
|
113
|
+
# "fall back to palette".
|
|
114
|
+
template_colors: list[tuple | None] = [None] * n
|
|
115
|
+
palette_needed = n
|
|
116
|
+
if color_template and (category or entity_class or scenario):
|
|
117
|
+
for i, label in enumerate(labels):
|
|
118
|
+
c = resolve_label_color(
|
|
119
|
+
label, color_template, category=category,
|
|
120
|
+
entity_class=entity_class, scenario=scenario,
|
|
121
|
+
)
|
|
122
|
+
if c is not None:
|
|
123
|
+
template_colors[i] = c
|
|
124
|
+
palette_needed -= 1
|
|
125
|
+
|
|
126
|
+
# Pick the palette based on how many palette slots we actually need.
|
|
127
|
+
# For the zero-template case, palette_needed == n, so the behaviour
|
|
128
|
+
# matches the pre-refactor code exactly.
|
|
129
|
+
if palette_needed <= 10:
|
|
130
|
+
cmap_colors = plt.colormaps['tab10'].colors
|
|
131
|
+
else:
|
|
132
|
+
cmap_colors = plt.colormaps['tab20'].colors
|
|
133
|
+
|
|
134
|
+
result: dict[str, tuple] = {}
|
|
135
|
+
palette_idx = 0
|
|
136
|
+
for i, label in enumerate(labels):
|
|
137
|
+
tc = template_colors[i]
|
|
138
|
+
if tc is not None:
|
|
139
|
+
result[label] = tc
|
|
140
|
+
continue
|
|
141
|
+
result[label] = cmap_colors[palette_idx % len(cmap_colors)]
|
|
142
|
+
palette_idx += 1
|
|
143
|
+
return result
|