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,888 @@
|
|
|
1
|
+
"""Loader and resolver for the per-project plot settings (colors).
|
|
2
|
+
|
|
3
|
+
The settings file maps legend labels to explicit RGB colors. It has
|
|
4
|
+
these label sections:
|
|
5
|
+
|
|
6
|
+
* ``categories`` — keyed by a category name declared by a plot entry via
|
|
7
|
+
``color_category``. Label lookup is *exact* (these are parameter or
|
|
8
|
+
result names whose casing is controlled by the pipeline).
|
|
9
|
+
* ``entities`` — keyed by an entity-class name (``nodeGroup`` /
|
|
10
|
+
``flowGroup`` / ``unit`` / ``connection`` / ``node``; legacy ``group``
|
|
11
|
+
still read for dispatch) declared via ``color_entity_class``. Label
|
|
12
|
+
lookup is *case-insensitive* (entity names come from the data and can be
|
|
13
|
+
mixed-case). An entity entry may be a bare color or a mapping
|
|
14
|
+
``{color: ..., neg_color: ...}`` whose ``neg_color`` colors the
|
|
15
|
+
entity's negative-side part of a mixed (both-signs) stacked column.
|
|
16
|
+
|
|
17
|
+
For backward compatibility the resolver also reads the legacy top-level
|
|
18
|
+
keys ``category`` / ``entity_class`` written by older projects' seeded
|
|
19
|
+
``plot_settings.yaml`` (migration window).
|
|
20
|
+
|
|
21
|
+
Any label not found falls back to the tab10/tab20 palette used by
|
|
22
|
+
:func:`build_shared_color_map`.
|
|
23
|
+
|
|
24
|
+
The bundled starting-point file lives at
|
|
25
|
+
``schemas/default_plot_settings.yaml`` and is copied into each project as
|
|
26
|
+
``plot_settings.yaml``. The loaded result is cached at the module level
|
|
27
|
+
keyed by ``(path, mtime)`` so repeated plot builds within the same process
|
|
28
|
+
don't re-read it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import yaml
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# (resolved_path, mtime_ns) -> parsed dict
|
|
42
|
+
_TEMPLATE_CACHE: dict[tuple[str, int], dict] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _default_path() -> Path:
|
|
46
|
+
from flextool._resources import package_data_path
|
|
47
|
+
return package_data_path("schemas/default_plot_settings.yaml")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_plot_settings_path(project_path: Path | None) -> Path:
|
|
51
|
+
"""Resolve the plot color template path for *project_path*.
|
|
52
|
+
|
|
53
|
+
Returns ``<project_path>/plot_settings.yaml`` when that file exists,
|
|
54
|
+
otherwise the bundled starting point
|
|
55
|
+
(``schemas/default_plot_settings.yaml``). This
|
|
56
|
+
keeps behavior byte-identical to before for any project that has no
|
|
57
|
+
``plot_settings.yaml`` while letting a per-project file override the
|
|
58
|
+
template for both PNG export and the result viewer.
|
|
59
|
+
"""
|
|
60
|
+
if project_path is not None:
|
|
61
|
+
candidate = Path(project_path) / "plot_settings.yaml"
|
|
62
|
+
if candidate.is_file():
|
|
63
|
+
return candidate
|
|
64
|
+
return _default_path()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_color_template(path: Path | None = None) -> dict:
|
|
68
|
+
"""Load the color template YAML from *path* (or the default location).
|
|
69
|
+
|
|
70
|
+
Returns an empty dict if the file is missing or unreadable, so
|
|
71
|
+
callers can treat "no template" uniformly. The result is cached
|
|
72
|
+
keyed by ``(resolved path, mtime_ns)`` — a subsequent call with an
|
|
73
|
+
unchanged file hits the cache.
|
|
74
|
+
"""
|
|
75
|
+
resolved = Path(path) if path is not None else _default_path()
|
|
76
|
+
try:
|
|
77
|
+
st = resolved.stat()
|
|
78
|
+
except OSError:
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
cache_key = (str(resolved), st.st_mtime_ns)
|
|
82
|
+
cached = _TEMPLATE_CACHE.get(cache_key)
|
|
83
|
+
if cached is not None:
|
|
84
|
+
return cached
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
with open(resolved, "r", encoding="utf-8") as f:
|
|
88
|
+
data = yaml.safe_load(f)
|
|
89
|
+
except (yaml.YAMLError, OSError) as exc:
|
|
90
|
+
logger.warning("Failed to load color template %s: %s", resolved, exc)
|
|
91
|
+
_TEMPLATE_CACHE[cache_key] = {}
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
if not isinstance(data, dict):
|
|
95
|
+
data = {}
|
|
96
|
+
|
|
97
|
+
# Also prune any stale entries for the same path with a different
|
|
98
|
+
# mtime — keeps the cache from growing unbounded over long sessions.
|
|
99
|
+
for key in list(_TEMPLATE_CACHE):
|
|
100
|
+
if key[0] == str(resolved) and key != cache_key:
|
|
101
|
+
del _TEMPLATE_CACHE[key]
|
|
102
|
+
|
|
103
|
+
_TEMPLATE_CACHE[cache_key] = data
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_color_value(value) -> tuple[float, float, float] | None:
|
|
108
|
+
"""Normalize a YAML color value to an ``(r, g, b)`` float tuple in 0..1.
|
|
109
|
+
|
|
110
|
+
Accepts:
|
|
111
|
+
* ``#RRGGBB`` hex strings (with or without the leading ``#``).
|
|
112
|
+
* ``[r, g, b]`` lists/tuples of ints (0..255) or floats (0..1).
|
|
113
|
+
|
|
114
|
+
Returns ``None`` for anything malformed; callers log the context.
|
|
115
|
+
"""
|
|
116
|
+
if value is None:
|
|
117
|
+
return None
|
|
118
|
+
if isinstance(value, str):
|
|
119
|
+
s = value.strip().lstrip("#")
|
|
120
|
+
if len(s) != 6:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
r = int(s[0:2], 16)
|
|
124
|
+
g = int(s[2:4], 16)
|
|
125
|
+
b = int(s[4:6], 16)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return None
|
|
128
|
+
return (r / 255.0, g / 255.0, b / 255.0)
|
|
129
|
+
if isinstance(value, (list, tuple)) and len(value) == 3:
|
|
130
|
+
try:
|
|
131
|
+
parts = [float(x) for x in value]
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
return None
|
|
134
|
+
# Decide 0..1 vs 0..255 by the max component.
|
|
135
|
+
max_part = max(parts)
|
|
136
|
+
min_part = min(parts)
|
|
137
|
+
if min_part < 0:
|
|
138
|
+
return None
|
|
139
|
+
if max_part <= 1.0:
|
|
140
|
+
# floats in 0..1
|
|
141
|
+
if any(p > 1.0 for p in parts):
|
|
142
|
+
return None
|
|
143
|
+
return (parts[0], parts[1], parts[2])
|
|
144
|
+
if max_part <= 255.0:
|
|
145
|
+
return (parts[0] / 255.0, parts[1] / 255.0, parts[2] / 255.0)
|
|
146
|
+
return None
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Categories whose labels are composite ``(type, item)`` tuples rendered
|
|
151
|
+
# into legend strings in one of two shapes:
|
|
152
|
+
# * ``"<type> | <item>"`` — bar plans (via _format_legend_labels)
|
|
153
|
+
# * ``"('<type>', '<item>')"`` — time-series stacks (str(tuple))
|
|
154
|
+
# For these categories we split the label, then try
|
|
155
|
+
# 1. ``<type>_<item>`` (e.g. ``slack_upward``)
|
|
156
|
+
# 2. ``<type>`` alone (e.g. ``unit``)
|
|
157
|
+
# before falling through. Plain (non-composite) labels still fall through
|
|
158
|
+
# to the exact-key lookup below.
|
|
159
|
+
_COMPOSITE_CATEGORIES: frozenset[str] = frozenset({"nodegroup_flows"})
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _split_composite_label(label: str) -> tuple[str, str] | None:
|
|
163
|
+
"""Split a composite legend label into ``(type, item)`` if possible.
|
|
164
|
+
|
|
165
|
+
Recognizes the two formats emitted by the plot pipeline for two-level
|
|
166
|
+
column stacks:
|
|
167
|
+
|
|
168
|
+
* ``"<type> | <item>"`` — produced by ``_format_legend_labels`` in the
|
|
169
|
+
bar-plan path.
|
|
170
|
+
* ``"('<type>', '<item>')"`` — produced by ``str(item)`` in the
|
|
171
|
+
time-series stack path when ``item`` is a 2-tuple.
|
|
172
|
+
|
|
173
|
+
Returns ``None`` for labels that don't match either shape (callers
|
|
174
|
+
should treat the label as non-composite and try a plain key lookup).
|
|
175
|
+
"""
|
|
176
|
+
if not isinstance(label, str):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
# " | " separator (bar plans).
|
|
180
|
+
if " | " in label:
|
|
181
|
+
parts = label.split(" | ", 1)
|
|
182
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
183
|
+
return parts[0], parts[1]
|
|
184
|
+
|
|
185
|
+
# Python tuple repr (time-series stacks). Use ast.literal_eval for
|
|
186
|
+
# safety — the string came from str(tuple(...)) of hashable scalars so
|
|
187
|
+
# it should parse cleanly; anything else falls through.
|
|
188
|
+
s = label.strip()
|
|
189
|
+
if s.startswith("(") and s.endswith(")"):
|
|
190
|
+
try:
|
|
191
|
+
import ast
|
|
192
|
+
parsed = ast.literal_eval(s)
|
|
193
|
+
except (ValueError, SyntaxError):
|
|
194
|
+
return None
|
|
195
|
+
if isinstance(parsed, tuple) and len(parsed) == 2:
|
|
196
|
+
a, b = parsed
|
|
197
|
+
if isinstance(a, str) and isinstance(b, str) and a and b:
|
|
198
|
+
return a, b
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _resolve_entity_value(raw):
|
|
203
|
+
"""Normalize an entity entry into ``(color_value, neg_color_value)``.
|
|
204
|
+
|
|
205
|
+
An entity entry may be either a bare color scalar (``"#RRGGBB"`` or
|
|
206
|
+
``[r, g, b]``) or a mapping ``{color: ..., neg_color: ...}``. Returns
|
|
207
|
+
the positive-side value and the optional negative-side value (the
|
|
208
|
+
latter ``None`` when the entry is a scalar or has no ``neg_color``).
|
|
209
|
+
"""
|
|
210
|
+
if isinstance(raw, dict):
|
|
211
|
+
return raw.get("color"), raw.get("neg_color")
|
|
212
|
+
return raw, None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# For the ``nodegroup_flows`` composite category each flow ``type`` is
|
|
216
|
+
# colored by its ``item`` (the participating entity), resolved in exactly
|
|
217
|
+
# one ``entities`` class — the ``type`` fixes the class (``from_unitGroup``
|
|
218
|
+
# items are ``group`` entities, ``from_unit`` items are ``unit`` entities,
|
|
219
|
+
# etc.). ``to_*`` types are the draw (negative) side and use the entity's
|
|
220
|
+
# ``neg_color`` when one is defined. Types absent from this table
|
|
221
|
+
# (``inflow`` / ``internal_losses`` / ``slack``) are group-property
|
|
222
|
+
# aggregates and stay category-colored.
|
|
223
|
+
_NODEGROUP_TYPE_TO_ENTITY_CLASS: dict[str, str] = {
|
|
224
|
+
"from_unitGroup": "flowGroup",
|
|
225
|
+
"to_unitGroup": "flowGroup",
|
|
226
|
+
"from_connectionGroup": "flowGroup",
|
|
227
|
+
"to_connectionGroup": "flowGroup",
|
|
228
|
+
"from_unit": "unit",
|
|
229
|
+
"to_unit": "unit",
|
|
230
|
+
"from_connection": "connection",
|
|
231
|
+
"to_connection": "connection",
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# Stacking band order for ``nodegroup_flows`` types (supply types first,
|
|
235
|
+
# draw types last; the series sign decides which side of zero each lands
|
|
236
|
+
# on, this only fixes the order *within* a side). Items inside an
|
|
237
|
+
# entity-routed band are ordered by their ``entities`` class file order
|
|
238
|
+
# (so the picker's entity reordering carries through); items inside a
|
|
239
|
+
# special band (slack upward/downward, internal_losses
|
|
240
|
+
# storages/units/connections) by ``categories.nodegroup_flows`` file order.
|
|
241
|
+
_NODEGROUP_TYPE_ORDER: tuple[str, ...] = (
|
|
242
|
+
"from_unitGroup",
|
|
243
|
+
"from_unit",
|
|
244
|
+
"from_connectionGroup",
|
|
245
|
+
"from_connection",
|
|
246
|
+
"inflow",
|
|
247
|
+
"slack",
|
|
248
|
+
"internal_losses",
|
|
249
|
+
"to_connection",
|
|
250
|
+
"to_connectionGroup",
|
|
251
|
+
"to_unit",
|
|
252
|
+
"to_unitGroup",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _lookup_entity_color_in_class(
|
|
257
|
+
template: dict, entity_class: str, name: str, negative: bool,
|
|
258
|
+
):
|
|
259
|
+
"""Look up *name* in a single ``entities`` class (case-insensitive).
|
|
260
|
+
|
|
261
|
+
Returns the raw color value (``neg_color`` when *negative* and the
|
|
262
|
+
entry defines one, else ``color``) or ``None`` when that class lists no
|
|
263
|
+
*name*. Unlike :func:`_lookup_entity_color` this does **not** search
|
|
264
|
+
the other classes — the nodegroup ``type`` already fixes the class.
|
|
265
|
+
"""
|
|
266
|
+
section = _entities_section(template)
|
|
267
|
+
class_map = section.get(entity_class)
|
|
268
|
+
if not isinstance(class_map, dict):
|
|
269
|
+
return None
|
|
270
|
+
name_lc = str(name).lower()
|
|
271
|
+
for key, val in class_map.items():
|
|
272
|
+
if str(key).lower() == name_lc:
|
|
273
|
+
color_val, neg_val = _resolve_entity_value(val)
|
|
274
|
+
if negative and neg_val is not None:
|
|
275
|
+
return neg_val
|
|
276
|
+
return color_val
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def resolve_label_color(
|
|
281
|
+
label: str,
|
|
282
|
+
template: dict,
|
|
283
|
+
category: str | None = None,
|
|
284
|
+
entity_class: str | None = None,
|
|
285
|
+
negative: bool = False,
|
|
286
|
+
scenario: bool = False,
|
|
287
|
+
) -> tuple[float, float, float] | None:
|
|
288
|
+
"""Resolve *label* to a color via the plot-settings *template*.
|
|
289
|
+
|
|
290
|
+
Lookup precedence:
|
|
291
|
+
* If *scenario* is True, check the top-level ``scenarios`` section with
|
|
292
|
+
a case-insensitive key match (single color; ``negative`` has no
|
|
293
|
+
effect). Used to color the scenario series of comparison plots.
|
|
294
|
+
* Else if *entity_class* is given, check the ``entities`` section
|
|
295
|
+
(legacy ``entity_class``) under ``[entity_class]`` with a
|
|
296
|
+
case-insensitive key match. An entity entry may be a bare color or
|
|
297
|
+
a mapping ``{color: ..., neg_color: ...}``. When *negative* is True
|
|
298
|
+
and the entry has a ``neg_color``, that color is returned; otherwise
|
|
299
|
+
the positive ``color`` is used.
|
|
300
|
+
* Else if *category* is given, check the ``categories`` section
|
|
301
|
+
(legacy ``category``) under ``[category]`` with an exact key match.
|
|
302
|
+
For composite-label categories (see ``_COMPOSITE_CATEGORIES``) the
|
|
303
|
+
label is first split on ``" | "`` or parsed as a 2-tuple repr and
|
|
304
|
+
looked up as ``<type>_<item>`` then ``<type>`` before falling back
|
|
305
|
+
to the exact-key lookup. Categories are always scalar; *negative*
|
|
306
|
+
has no effect.
|
|
307
|
+
* Otherwise return ``None`` and let the caller fall back to the palette.
|
|
308
|
+
|
|
309
|
+
Both the new top-level keys (``categories`` / ``entities``) and the
|
|
310
|
+
legacy keys (``category`` / ``entity_class``) are accepted so that
|
|
311
|
+
projects whose seeded ``plot_settings.yaml`` still uses the old schema
|
|
312
|
+
keep working (migration window).
|
|
313
|
+
|
|
314
|
+
Returns a normalized ``(r, g, b)`` float tuple in ``0..1`` or
|
|
315
|
+
``None`` if the template has no entry (or the entry is malformed).
|
|
316
|
+
Malformed values are logged but do not raise.
|
|
317
|
+
"""
|
|
318
|
+
if not isinstance(template, dict) or label is None:
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
raw = None
|
|
322
|
+
if scenario:
|
|
323
|
+
section = template.get("scenarios")
|
|
324
|
+
if isinstance(section, dict):
|
|
325
|
+
label_lc = str(label).lower()
|
|
326
|
+
for key, val in section.items():
|
|
327
|
+
if str(key).lower() == label_lc:
|
|
328
|
+
color_val, _neg = _resolve_entity_value(val)
|
|
329
|
+
raw = color_val
|
|
330
|
+
break
|
|
331
|
+
elif entity_class:
|
|
332
|
+
section = template.get("entities")
|
|
333
|
+
if not isinstance(section, dict):
|
|
334
|
+
section = template.get("entity_class")
|
|
335
|
+
if isinstance(section, dict):
|
|
336
|
+
class_map = section.get(entity_class)
|
|
337
|
+
if isinstance(class_map, dict):
|
|
338
|
+
# Case-insensitive lookup. Build a lowercase view on the
|
|
339
|
+
# fly — these dicts are small (tens of entries at most).
|
|
340
|
+
label_lc = str(label).lower()
|
|
341
|
+
for key, val in class_map.items():
|
|
342
|
+
if str(key).lower() == label_lc:
|
|
343
|
+
color_val, neg_val = _resolve_entity_value(val)
|
|
344
|
+
raw = neg_val if (negative and neg_val is not None) else color_val
|
|
345
|
+
break
|
|
346
|
+
elif category and category in _COMPOSITE_CATEGORIES:
|
|
347
|
+
# Composite categories (nodegroup_flows): entity-routed types color
|
|
348
|
+
# by their ``item`` via the ``entities`` section — which works even
|
|
349
|
+
# when ``categories.<category>`` is absent — while group-property
|
|
350
|
+
# types (inflow / internal_losses / slack) stay category-keyed.
|
|
351
|
+
section = template.get("categories")
|
|
352
|
+
if not isinstance(section, dict):
|
|
353
|
+
section = template.get("category")
|
|
354
|
+
cat_map = section.get(category) if isinstance(section, dict) else None
|
|
355
|
+
if not isinstance(cat_map, dict):
|
|
356
|
+
cat_map = {}
|
|
357
|
+
entity_routed = False
|
|
358
|
+
parts = _split_composite_label(label)
|
|
359
|
+
if parts is not None:
|
|
360
|
+
type_key, item_key = parts
|
|
361
|
+
entity_cls = _NODEGROUP_TYPE_TO_ENTITY_CLASS.get(type_key)
|
|
362
|
+
if entity_cls is not None:
|
|
363
|
+
# Entity-routed type: color by the item via its single
|
|
364
|
+
# entities class; ``to_*`` is the draw (negative) side. No
|
|
365
|
+
# type-color fallback — un-listed items go to the palette,
|
|
366
|
+
# otherwise a per-type color would re-collapse every item to
|
|
367
|
+
# one hue (the bug this routing fixes).
|
|
368
|
+
entity_routed = True
|
|
369
|
+
raw = _lookup_entity_color_in_class(
|
|
370
|
+
template, entity_cls, item_key,
|
|
371
|
+
negative=type_key.startswith("to_"),
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
# Group-property aggregate: type_item first then type.
|
|
375
|
+
raw = cat_map.get(f"{type_key}_{item_key}")
|
|
376
|
+
if raw is None:
|
|
377
|
+
raw = cat_map.get(type_key)
|
|
378
|
+
# Exact-key fallback for non-composite labels / verbatim entries;
|
|
379
|
+
# entity-routed misses fall through to the palette instead.
|
|
380
|
+
if raw is None and not entity_routed:
|
|
381
|
+
raw = cat_map.get(label)
|
|
382
|
+
elif category:
|
|
383
|
+
section = template.get("categories")
|
|
384
|
+
if not isinstance(section, dict):
|
|
385
|
+
section = template.get("category")
|
|
386
|
+
if isinstance(section, dict):
|
|
387
|
+
cat_map = section.get(category)
|
|
388
|
+
if isinstance(cat_map, dict):
|
|
389
|
+
raw = cat_map.get(label)
|
|
390
|
+
|
|
391
|
+
if raw is None:
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
color = _parse_color_value(raw)
|
|
395
|
+
if color is None:
|
|
396
|
+
logger.warning(
|
|
397
|
+
"Color template: malformed value %r for label %r "
|
|
398
|
+
"(category=%r, entity_class=%r)",
|
|
399
|
+
raw, label, category, entity_class,
|
|
400
|
+
)
|
|
401
|
+
return color
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def template_label_order(
|
|
405
|
+
template: dict,
|
|
406
|
+
category: str | None = None,
|
|
407
|
+
entity_class: str | None = None,
|
|
408
|
+
scenario: bool = False,
|
|
409
|
+
) -> list[str]:
|
|
410
|
+
"""Return the label keys of a template section in **file order**.
|
|
411
|
+
|
|
412
|
+
This is the single source of file-defined stacking/legend order. Given
|
|
413
|
+
a loaded *template* and an optional *category* or *entity_class*, it
|
|
414
|
+
returns the keys of ``categories[category]`` (exact) or
|
|
415
|
+
``entities[entity_class]`` (case-insensitive, but the file's canonical
|
|
416
|
+
key spelling is returned) in YAML insertion order — which is dict
|
|
417
|
+
insertion order, preserved by the loader.
|
|
418
|
+
|
|
419
|
+
Both the new top-level keys (``categories`` / ``entities``) and the
|
|
420
|
+
legacy keys (``category`` / ``entity_class``) are honored (migration
|
|
421
|
+
window), matching :func:`resolve_label_color`.
|
|
422
|
+
|
|
423
|
+
Returns an empty list when the template has no matching section. When
|
|
424
|
+
both *category* and *entity_class* are given, *entity_class* wins
|
|
425
|
+
(matching the lookup precedence in :func:`resolve_label_color`).
|
|
426
|
+
"""
|
|
427
|
+
if not isinstance(template, dict):
|
|
428
|
+
return []
|
|
429
|
+
|
|
430
|
+
if scenario:
|
|
431
|
+
section = template.get("scenarios")
|
|
432
|
+
if isinstance(section, dict):
|
|
433
|
+
return [str(k) for k in section.keys()]
|
|
434
|
+
return []
|
|
435
|
+
|
|
436
|
+
if entity_class:
|
|
437
|
+
section = template.get("entities")
|
|
438
|
+
if not isinstance(section, dict):
|
|
439
|
+
section = template.get("entity_class")
|
|
440
|
+
if isinstance(section, dict):
|
|
441
|
+
class_map = section.get(entity_class)
|
|
442
|
+
if isinstance(class_map, dict):
|
|
443
|
+
return [str(k) for k in class_map.keys()]
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
if category:
|
|
447
|
+
section = template.get("categories")
|
|
448
|
+
if not isinstance(section, dict):
|
|
449
|
+
section = template.get("category")
|
|
450
|
+
if isinstance(section, dict):
|
|
451
|
+
cat_map = section.get(category)
|
|
452
|
+
if isinstance(cat_map, dict):
|
|
453
|
+
return [str(k) for k in cat_map.keys()]
|
|
454
|
+
return []
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _order_composite_labels(
|
|
458
|
+
labels: list[str], template: dict, category: str,
|
|
459
|
+
) -> list[str]:
|
|
460
|
+
"""Order ``nodegroup_flows`` composite ``(type, item)`` labels.
|
|
461
|
+
|
|
462
|
+
Bands follow :data:`_NODEGROUP_TYPE_ORDER`. Within an entity-routed
|
|
463
|
+
band items follow their ``entities`` class file order (honoring the
|
|
464
|
+
picker's reordering); within a special band they follow
|
|
465
|
+
``categories.<category>`` file order. Labels that don't parse as a
|
|
466
|
+
composite, and items not listed anywhere, sort to the tail by name.
|
|
467
|
+
The result is a permutation of *labels* (stable on ties via the
|
|
468
|
+
original index).
|
|
469
|
+
"""
|
|
470
|
+
band_index = {t: i for i, t in enumerate(_NODEGROUP_TYPE_ORDER)}
|
|
471
|
+
n_band = len(_NODEGROUP_TYPE_ORDER)
|
|
472
|
+
|
|
473
|
+
entity_orders: dict[str, dict[str, int]] = {}
|
|
474
|
+
for cls in set(_NODEGROUP_TYPE_TO_ENTITY_CLASS.values()):
|
|
475
|
+
order = template_label_order(template, entity_class=cls)
|
|
476
|
+
entity_orders[cls] = {str(k).lower(): i for i, k in enumerate(order)}
|
|
477
|
+
|
|
478
|
+
cat_order = template_label_order(template, category=category)
|
|
479
|
+
special_index = {str(k): i for i, k in enumerate(cat_order)}
|
|
480
|
+
n_special = len(special_index)
|
|
481
|
+
|
|
482
|
+
def sort_key(item):
|
|
483
|
+
idx, label = item
|
|
484
|
+
parts = _split_composite_label(label)
|
|
485
|
+
if parts is None:
|
|
486
|
+
return (n_band, 0, str(label), idx)
|
|
487
|
+
type_key, item_key = parts
|
|
488
|
+
band = band_index.get(type_key, n_band)
|
|
489
|
+
entity_cls = _NODEGROUP_TYPE_TO_ENTITY_CLASS.get(type_key)
|
|
490
|
+
if entity_cls is not None:
|
|
491
|
+
class_order = entity_orders[entity_cls]
|
|
492
|
+
within = class_order.get(str(item_key).lower(), len(class_order))
|
|
493
|
+
else:
|
|
494
|
+
within = special_index.get(
|
|
495
|
+
f"{type_key}_{item_key}",
|
|
496
|
+
special_index.get(type_key, n_special),
|
|
497
|
+
)
|
|
498
|
+
return (band, within, str(item_key), idx)
|
|
499
|
+
|
|
500
|
+
return [lbl for _, lbl in sorted(enumerate(labels), key=sort_key)]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def order_labels_by_template(
|
|
504
|
+
labels: list[str],
|
|
505
|
+
template: dict,
|
|
506
|
+
category: str | None = None,
|
|
507
|
+
entity_class: str | None = None,
|
|
508
|
+
scenario: bool = False,
|
|
509
|
+
) -> list[str]:
|
|
510
|
+
"""Order *labels* by template file order, appending unlisted tail sorted.
|
|
511
|
+
|
|
512
|
+
Labels present in the template section (per
|
|
513
|
+
:func:`template_label_order`) come first in **file order**; labels not
|
|
514
|
+
in the section are appended **sorted alphabetically** (the historical
|
|
515
|
+
fallback for the tail).
|
|
516
|
+
|
|
517
|
+
Matching mirrors :func:`resolve_label_color`:
|
|
518
|
+
|
|
519
|
+
* ``entity_class`` lookups are **case-insensitive**.
|
|
520
|
+
* ``category`` lookups are **exact**, except composite categories (see
|
|
521
|
+
``_COMPOSITE_CATEGORIES``) whose labels are matched on
|
|
522
|
+
``<type>_<item>`` then ``<type>`` after splitting.
|
|
523
|
+
|
|
524
|
+
The returned list is a permutation of *labels* (no labels are dropped
|
|
525
|
+
or duplicated even if a file key has no matching label).
|
|
526
|
+
"""
|
|
527
|
+
# Composite categories (nodegroup_flows) route items to entity classes
|
|
528
|
+
# for both color and order, so they use a dedicated band+entity sorter
|
|
529
|
+
# rather than the single category-section file order below.
|
|
530
|
+
if category and category in _COMPOSITE_CATEGORIES and not entity_class:
|
|
531
|
+
return _order_composite_labels(labels, template, category)
|
|
532
|
+
|
|
533
|
+
file_order = template_label_order(
|
|
534
|
+
template, category=category, entity_class=entity_class, scenario=scenario)
|
|
535
|
+
if not file_order:
|
|
536
|
+
return sorted(labels, key=str)
|
|
537
|
+
|
|
538
|
+
remaining = list(labels)
|
|
539
|
+
ordered: list[str] = []
|
|
540
|
+
|
|
541
|
+
if entity_class or scenario:
|
|
542
|
+
# Case-insensitive: bucket remaining labels by lowercase.
|
|
543
|
+
by_lc: dict[str, list[str]] = {}
|
|
544
|
+
for lbl in remaining:
|
|
545
|
+
by_lc.setdefault(str(lbl).lower(), []).append(lbl)
|
|
546
|
+
consumed: set[int] = set()
|
|
547
|
+
for key in file_order:
|
|
548
|
+
bucket = by_lc.get(str(key).lower())
|
|
549
|
+
if not bucket:
|
|
550
|
+
continue
|
|
551
|
+
for lbl in bucket:
|
|
552
|
+
lid = id(lbl)
|
|
553
|
+
if lid in consumed:
|
|
554
|
+
continue
|
|
555
|
+
consumed.add(lid)
|
|
556
|
+
ordered.append(lbl)
|
|
557
|
+
tail = [lbl for lbl in remaining if id(lbl) not in consumed]
|
|
558
|
+
return ordered + sorted(tail, key=str)
|
|
559
|
+
|
|
560
|
+
# Category path (exact + composite split).
|
|
561
|
+
composite = bool(category) and category in _COMPOSITE_CATEGORIES
|
|
562
|
+
|
|
563
|
+
def _match_key(label) -> str | None:
|
|
564
|
+
"""Return the file_order key this label matches, or None."""
|
|
565
|
+
if composite:
|
|
566
|
+
parts = _split_composite_label(label)
|
|
567
|
+
if parts is not None:
|
|
568
|
+
type_key, item_key = parts
|
|
569
|
+
joined = f"{type_key}_{item_key}"
|
|
570
|
+
if joined in file_order_set:
|
|
571
|
+
return joined
|
|
572
|
+
if type_key in file_order_set:
|
|
573
|
+
return type_key
|
|
574
|
+
if label in file_order_set:
|
|
575
|
+
return label
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
file_order_set = set(file_order)
|
|
579
|
+
# Group labels by the file key they match (in file order).
|
|
580
|
+
matched: dict[str, list[str]] = {}
|
|
581
|
+
consumed_idx: set[int] = set()
|
|
582
|
+
for i, lbl in enumerate(remaining):
|
|
583
|
+
key = _match_key(lbl)
|
|
584
|
+
if key is not None:
|
|
585
|
+
matched.setdefault(key, []).append(lbl)
|
|
586
|
+
consumed_idx.add(i)
|
|
587
|
+
for key in file_order:
|
|
588
|
+
for lbl in matched.get(key, []):
|
|
589
|
+
ordered.append(lbl)
|
|
590
|
+
tail = [lbl for i, lbl in enumerate(remaining) if i not in consumed_idx]
|
|
591
|
+
return ordered + sorted(tail, key=str)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# ---------------------------------------------------------------------------
|
|
595
|
+
# Dispatch color / order resolution
|
|
596
|
+
# ---------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
# Entity classes the dispatch resolver searches, in precedence order. A
|
|
599
|
+
# dispatch column that is an entity name (a flowGroup aggregate, a unit, a
|
|
600
|
+
# connection, …) may live in any of these ``entities`` subsections; the
|
|
601
|
+
# first one with a matching (case-insensitive) key wins. ``group`` is kept
|
|
602
|
+
# last as a LEGACY fallback so projects seeded before the
|
|
603
|
+
# nodeGroup/flowGroup split still resolve their old ``entities.group``
|
|
604
|
+
# colors; new seeds write ``nodeGroup`` / ``flowGroup``.
|
|
605
|
+
# Class resolution order for a dispatch column → entity color/order. A
|
|
606
|
+
# dispatch band is a FLOW: a flowGroup aggregate, or an individual unit /
|
|
607
|
+
# connection / node. A ``nodeGroup`` is the plot's CONTAINER, never a band,
|
|
608
|
+
# so it must have the LOWEST priority — otherwise a nodeGroup that happens to
|
|
609
|
+
# share a name with a flowGroup aggregate (e.g. a "PV_flows" nodeGroup and a
|
|
610
|
+
# "PV_flows" flowGroup) would shadow it, and editing the flowGroup entry would
|
|
611
|
+
# silently do nothing. First case-insensitive match wins (see
|
|
612
|
+
# ``_lookup_entity_color`` / ``template_entity_names``).
|
|
613
|
+
_DISPATCH_ENTITY_CLASSES: tuple[str, ...] = (
|
|
614
|
+
"flowGroup",
|
|
615
|
+
"unit",
|
|
616
|
+
"connection",
|
|
617
|
+
"node",
|
|
618
|
+
"nodeGroup",
|
|
619
|
+
"group",
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _entities_section(template: dict) -> dict:
|
|
624
|
+
"""Return the ``entities`` (or legacy ``entity_class``) mapping or {}."""
|
|
625
|
+
if not isinstance(template, dict):
|
|
626
|
+
return {}
|
|
627
|
+
section = template.get("entities")
|
|
628
|
+
if not isinstance(section, dict):
|
|
629
|
+
section = template.get("entity_class")
|
|
630
|
+
return section if isinstance(section, dict) else {}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _dispatch_special_section(template: dict) -> dict:
|
|
634
|
+
"""Return the ``categories.dispatch`` mapping or {}."""
|
|
635
|
+
if not isinstance(template, dict):
|
|
636
|
+
return {}
|
|
637
|
+
cats = template.get("categories")
|
|
638
|
+
if not isinstance(cats, dict):
|
|
639
|
+
cats = template.get("category")
|
|
640
|
+
if not isinstance(cats, dict):
|
|
641
|
+
return {}
|
|
642
|
+
block = cats.get("dispatch")
|
|
643
|
+
return block if isinstance(block, dict) else {}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _extract_dispatch_entity_name(column: str) -> str | None:
|
|
647
|
+
"""Extract the entity name from a dispatch column name.
|
|
648
|
+
|
|
649
|
+
Dispatch column names come in several shapes (see
|
|
650
|
+
``scenario_comparison/dispatch_data.py``):
|
|
651
|
+
|
|
652
|
+
* bare entity name — a processGroup aggregate, e.g. ``"coal"`` ;
|
|
653
|
+
* ``"(process, node)"`` or ``"(connection)"`` repr composites for
|
|
654
|
+
not-in-aggregate flows — the *process*/*connection* is the entity ;
|
|
655
|
+
* node-level columns ``"<unit>_out"`` / ``"<unit>_in"`` /
|
|
656
|
+
``"<conn>_left"`` / ``"<conn>_right"`` from the single-node path ;
|
|
657
|
+
* any of the above with a trailing ``_pos`` / ``_neg`` added by the
|
|
658
|
+
mixed-sign split in ``_order_dispatch_columns``.
|
|
659
|
+
|
|
660
|
+
Returns the bare entity name, or ``None`` if *column* is not a string.
|
|
661
|
+
"""
|
|
662
|
+
if not isinstance(column, str):
|
|
663
|
+
return None
|
|
664
|
+
name = column
|
|
665
|
+
|
|
666
|
+
# Strip the mixed-sign split suffix first (it is appended last).
|
|
667
|
+
if name.endswith("_pos"):
|
|
668
|
+
name = name[:-4]
|
|
669
|
+
elif name.endswith("_neg"):
|
|
670
|
+
name = name[:-4]
|
|
671
|
+
|
|
672
|
+
# ``(process, node)`` / ``(connection)`` repr composites: take the
|
|
673
|
+
# first element (the process / connection entity).
|
|
674
|
+
s = name.strip()
|
|
675
|
+
if s.startswith("(") and s.endswith(")"):
|
|
676
|
+
inner = s[1:-1]
|
|
677
|
+
# First comma-separated field, stripped of surrounding quotes.
|
|
678
|
+
first = inner.split(",", 1)[0].strip().strip("'\"")
|
|
679
|
+
return first or None
|
|
680
|
+
|
|
681
|
+
# Node-level flow suffixes from the single-node dispatch path.
|
|
682
|
+
for suffix in ("_left_right", "_left", "_right", "_out", "_in"):
|
|
683
|
+
if name.endswith(suffix):
|
|
684
|
+
base = name[: -len(suffix)]
|
|
685
|
+
if base:
|
|
686
|
+
return base
|
|
687
|
+
|
|
688
|
+
return name or None
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _lookup_entity_color(entities: dict, name: str, negative: bool):
|
|
692
|
+
"""Look up *name* across the dispatch entity classes (case-insensitive).
|
|
693
|
+
|
|
694
|
+
Returns the raw color value (positive side, or ``neg_color`` when
|
|
695
|
+
*negative* and present) or ``None`` if no class lists *name*.
|
|
696
|
+
"""
|
|
697
|
+
name_str = str(name)
|
|
698
|
+
name_lc = name_str.lower()
|
|
699
|
+
# Two passes: EXACT case first (across all classes, flowGroup first), then
|
|
700
|
+
# a case-insensitive fallback. The exact pass keeps a unit ``wind`` and a
|
|
701
|
+
# flowGroup ``Wind`` distinct — a node's ``wind_out`` band then takes the
|
|
702
|
+
# unit colour and a nodeGroup's ``Wind`` aggregate the flowGroup colour,
|
|
703
|
+
# instead of both collapsing to whichever class comes first case-folded.
|
|
704
|
+
# This mirrors the exact-then-lower order index in
|
|
705
|
+
# ``resolve_dispatch_colors_and_order`` so colour and order agree.
|
|
706
|
+
for exact in (True, False):
|
|
707
|
+
for cls in _DISPATCH_ENTITY_CLASSES:
|
|
708
|
+
class_map = entities.get(cls)
|
|
709
|
+
if not isinstance(class_map, dict):
|
|
710
|
+
continue
|
|
711
|
+
for key, val in class_map.items():
|
|
712
|
+
hit = (str(key) == name_str) if exact else (
|
|
713
|
+
str(key).lower() == name_lc
|
|
714
|
+
)
|
|
715
|
+
if hit:
|
|
716
|
+
color_val, neg_val = _resolve_entity_value(val)
|
|
717
|
+
if negative and neg_val is not None:
|
|
718
|
+
return neg_val
|
|
719
|
+
return color_val
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def template_entity_names(template: dict) -> list[str]:
|
|
724
|
+
"""Return all entity names listed in the template, in file order.
|
|
725
|
+
|
|
726
|
+
Concatenates the keys of every dispatch entity class
|
|
727
|
+
(``group`` / ``unit`` / ``connection`` / ``node``) in file order. This
|
|
728
|
+
is the data-independent stacking order seed for the dispatch resolver:
|
|
729
|
+
bare-entity dispatch columns match these names, so passing them to
|
|
730
|
+
:func:`resolve_dispatch_colors_and_order` yields a stable
|
|
731
|
+
``config_order`` without first enumerating the data columns.
|
|
732
|
+
"""
|
|
733
|
+
names: list[str] = []
|
|
734
|
+
for cls in _DISPATCH_ENTITY_CLASSES:
|
|
735
|
+
names.extend(template_label_order(template, entity_class=cls))
|
|
736
|
+
return names
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def resolve_dispatch_colors_and_order(
|
|
740
|
+
template: dict,
|
|
741
|
+
columns,
|
|
742
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
743
|
+
"""Build dispatch ``colors`` + ``config_order`` from a plot-settings template.
|
|
744
|
+
|
|
745
|
+
*columns* is the set of dispatch column names that may appear (the
|
|
746
|
+
union across scenarios, base names — i.e. before the mixed-sign
|
|
747
|
+
``_pos`` / ``_neg`` split). The result reproduces the shape that the
|
|
748
|
+
legacy ``config['positive'|'negative']`` parsing produced:
|
|
749
|
+
|
|
750
|
+
* ``colors`` maps each column name to its color value (a ``#RRGGBB``
|
|
751
|
+
hex string, an ``[r, g, b]`` list, or a matplotlib color name). A
|
|
752
|
+
mixed-sign entity whose ``entities`` entry has ``neg_color`` also gets
|
|
753
|
+
a ``"<column>_neg"`` entry carrying that distinct negative-side color;
|
|
754
|
+
the ``"<column>_pos"`` part inherits the base color via
|
|
755
|
+
``get_color_for_column``.
|
|
756
|
+
* ``config_order`` lists the **entity** column base names in matplotlib
|
|
757
|
+
stacking order (bottom-to-top), following ``entities`` file order. As
|
|
758
|
+
in the legacy path the list is built top→bottom then reversed for
|
|
759
|
+
matplotlib. Special tokens are deliberately **omitted** from
|
|
760
|
+
``config_order`` so that ``_order_dispatch_columns`` keeps placing them
|
|
761
|
+
at their fixed ``POSITIVE_SPECIAL`` / ``NEGATIVE_SPECIAL`` positions
|
|
762
|
+
(its ``else`` branch is byte-for-byte the historical behavior when no
|
|
763
|
+
entity ordering is supplied). Special-token *colors* still flow
|
|
764
|
+
through ``colors``.
|
|
765
|
+
|
|
766
|
+
Resolution per column:
|
|
767
|
+
|
|
768
|
+
1. **Special token** (exact key in ``categories.dispatch``) — e.g.
|
|
769
|
+
``LossOfLoad``, ``Charge``, ``internal_losses``. Contributes a color
|
|
770
|
+
only; ordering stays pipeline-fixed.
|
|
771
|
+
2. **Entity** — extract the entity name (bare / composite /
|
|
772
|
+
node-level, see :func:`_extract_dispatch_entity_name`) and look it
|
|
773
|
+
up case-insensitively across ``entities`` ``nodeGroup`` /
|
|
774
|
+
``flowGroup`` / ``unit`` / ``connection`` / ``node`` (and legacy
|
|
775
|
+
``group``). Contributes both a color and a ``config_order`` position.
|
|
776
|
+
3. **Fallback** — left out of ``colors`` and ``config_order`` so the
|
|
777
|
+
downstream ``_auto_assign_node_colors_with_existing`` palette (seeded
|
|
778
|
+
from the historical ``DEFAULT_SPECIAL_COLORS``) assigns it and
|
|
779
|
+
std-dev ordering places it, exactly as before.
|
|
780
|
+
|
|
781
|
+
For a project whose template has no dispatch entities (the common
|
|
782
|
+
case — the bundled default ships ``entities`` empty), only the special
|
|
783
|
+
tokens resolve (to the ``categories.dispatch`` values, which equal the
|
|
784
|
+
old ``DEFAULT_SPECIAL_COLORS``); ``config_order`` comes back empty, so
|
|
785
|
+
``_order_dispatch_columns`` takes its ``else`` branch and every column —
|
|
786
|
+
specials included — is ordered exactly as before.
|
|
787
|
+
"""
|
|
788
|
+
special = _dispatch_special_section(template)
|
|
789
|
+
entities = _entities_section(template)
|
|
790
|
+
cols = [str(c) for c in (columns or [])]
|
|
791
|
+
|
|
792
|
+
colors: dict[str, str] = {}
|
|
793
|
+
|
|
794
|
+
# Entity columns in template file order, then the unlisted tail. We
|
|
795
|
+
# reuse the case-insensitive ordering machinery so file order is the
|
|
796
|
+
# single source of truth. Build the union of entity-class file orders
|
|
797
|
+
# (group, unit, connection, node) as the order key.
|
|
798
|
+
entity_file_order: list[str] = []
|
|
799
|
+
for cls in _DISPATCH_ENTITY_CLASSES:
|
|
800
|
+
entity_file_order.extend(template_label_order(template, entity_class=cls))
|
|
801
|
+
# Order index, first-occurrence-wins (``setdefault``) so it agrees with
|
|
802
|
+
# ``_lookup_entity_color``'s first-match-wins class precedence
|
|
803
|
+
# (``_DISPATCH_ENTITY_CLASSES`` has flowGroup first). Keep BOTH an
|
|
804
|
+
# exact-case and a lowercase map: the exact map is consulted first so a
|
|
805
|
+
# flowGroup ``Wind`` and a unit ``wind`` keep DISTINCT positions — a
|
|
806
|
+
# nodeGroup's ``Wind`` aggregate band then tracks the flowGroup order and a
|
|
807
|
+
# node's ``wind_out`` band tracks the unit order, instead of one silently
|
|
808
|
+
# overwriting the other in a flat case-folded, last-wins index (which
|
|
809
|
+
# pinned flowGroup bands to an unrelated unit/node slot). The lowercase
|
|
810
|
+
# map preserves case-insensitive tolerance as a fallback.
|
|
811
|
+
entity_order_index: dict[str, int] = {}
|
|
812
|
+
entity_order_index_lc: dict[str, int] = {}
|
|
813
|
+
for i, k in enumerate(entity_file_order):
|
|
814
|
+
entity_order_index.setdefault(str(k), i)
|
|
815
|
+
entity_order_index_lc.setdefault(str(k).lower(), i)
|
|
816
|
+
|
|
817
|
+
def _entity_order_pos(name: str) -> int:
|
|
818
|
+
if name in entity_order_index:
|
|
819
|
+
return entity_order_index[name]
|
|
820
|
+
return entity_order_index_lc.get(
|
|
821
|
+
name.lower(), len(entity_file_order),
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
entity_cols: list[str] = []
|
|
825
|
+
for col in cols:
|
|
826
|
+
if col in special:
|
|
827
|
+
color = special.get(col)
|
|
828
|
+
if color is not None:
|
|
829
|
+
colors[col] = color
|
|
830
|
+
continue
|
|
831
|
+
name = _extract_dispatch_entity_name(col)
|
|
832
|
+
if name is None:
|
|
833
|
+
continue
|
|
834
|
+
pos_color = _lookup_entity_color(entities, name, negative=False)
|
|
835
|
+
if pos_color is None:
|
|
836
|
+
continue
|
|
837
|
+
neg_color = _lookup_entity_color(entities, name, negative=True)
|
|
838
|
+
is_neg_part = isinstance(col, str) and col.endswith("_neg")
|
|
839
|
+
# The column may itself already be the negative-side split part
|
|
840
|
+
# (``<col>_neg``) — color it with ``neg_color``; otherwise it is the
|
|
841
|
+
# positive / whole column → ``color``. ``get_color_for_column`` does
|
|
842
|
+
# an exact-key lookup, so we register the precise rendered name.
|
|
843
|
+
if is_neg_part and neg_color is not None and neg_color != pos_color:
|
|
844
|
+
colors[col] = neg_color
|
|
845
|
+
else:
|
|
846
|
+
colors[col] = pos_color
|
|
847
|
+
# A whole (not-yet-split) mixed column is split downstream into
|
|
848
|
+
# ``<col>_pos`` / ``<col>_neg``; register the ``<col>_neg`` key so
|
|
849
|
+
# its negative part picks up a distinct ``neg_color`` when set.
|
|
850
|
+
if (
|
|
851
|
+
not is_neg_part
|
|
852
|
+
and not col.endswith("_pos")
|
|
853
|
+
and neg_color is not None
|
|
854
|
+
and neg_color != pos_color
|
|
855
|
+
):
|
|
856
|
+
colors[f"{col}_neg"] = neg_color
|
|
857
|
+
entity_cols.append(col)
|
|
858
|
+
|
|
859
|
+
# --- Order (top → bottom; reversed at the end for matplotlib) ---
|
|
860
|
+
# Only entity columns drive config_order, in template file order.
|
|
861
|
+
# Special tokens are left out so their fixed pos/neg placement (and the
|
|
862
|
+
# historical std-dev ordering of unlisted columns) is preserved.
|
|
863
|
+
ordered_entities = sorted(
|
|
864
|
+
entity_cols,
|
|
865
|
+
key=lambda c: _entity_order_pos(str(_extract_dispatch_entity_name(c))),
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
config_order = list(ordered_entities)
|
|
869
|
+
config_order.reverse()
|
|
870
|
+
|
|
871
|
+
return colors, config_order
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _clear_cache() -> None:
|
|
875
|
+
"""Clear the module-level cache (test hook)."""
|
|
876
|
+
_TEMPLATE_CACHE.clear()
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
__all__ = [
|
|
880
|
+
"load_color_template",
|
|
881
|
+
"resolve_plot_settings_path",
|
|
882
|
+
"resolve_label_color",
|
|
883
|
+
"template_label_order",
|
|
884
|
+
"order_labels_by_template",
|
|
885
|
+
"resolve_dispatch_colors_and_order",
|
|
886
|
+
"template_entity_names",
|
|
887
|
+
"_clear_cache",
|
|
888
|
+
]
|