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,691 @@
|
|
|
1
|
+
"""Dispatch area plots: render and save stacked-area dispatch plots for nodeGroups and nodes."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import matplotlib
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from matplotlib.figure import Figure
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from flextool.scenario_comparison.constants import DEFAULT_SPECIAL_COLORS
|
|
11
|
+
from flextool.scenario_comparison.data_models import TimeSeriesResults, DispatchMappings
|
|
12
|
+
from flextool.scenario_comparison.dispatch_data import (
|
|
13
|
+
prepare_dispatch_data,
|
|
14
|
+
prepare_node_dispatch_data,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _auto_assign_node_colors_with_existing(
|
|
19
|
+
columns, existing: dict[str, str | None],
|
|
20
|
+
) -> dict[str, str]:
|
|
21
|
+
"""Auto-assign tab20 colors for columns not already in *existing*."""
|
|
22
|
+
cmap = plt.cm.tab20(np.linspace(0, 1, 20))
|
|
23
|
+
colors = dict(existing)
|
|
24
|
+
# Also include special colors as defaults
|
|
25
|
+
for col, color in DEFAULT_SPECIAL_COLORS.items():
|
|
26
|
+
if col not in colors:
|
|
27
|
+
colors[col] = color
|
|
28
|
+
color_idx = 0
|
|
29
|
+
for col in columns:
|
|
30
|
+
col_str = str(col)
|
|
31
|
+
if col_str in colors and colors[col_str] is not None:
|
|
32
|
+
continue
|
|
33
|
+
# Check base name (_in/_out suffix)
|
|
34
|
+
base = col_str
|
|
35
|
+
if col_str.endswith('_in'):
|
|
36
|
+
base = col_str[:-3]
|
|
37
|
+
elif col_str.endswith('_out'):
|
|
38
|
+
base = col_str[:-4]
|
|
39
|
+
if base in colors and colors[base] is not None:
|
|
40
|
+
colors[col_str] = colors[base]
|
|
41
|
+
continue
|
|
42
|
+
colors[col_str] = matplotlib.colors.rgb2hex(cmap[color_idx % 20])
|
|
43
|
+
color_idx += 1
|
|
44
|
+
return colors
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_color_for_column(col: str, colors_dict: dict[str, str | None]) -> str:
|
|
48
|
+
"""Get color for a column, handling _in/_out suffixes."""
|
|
49
|
+
# Direct lookup
|
|
50
|
+
if col in colors_dict and colors_dict[col] is not None:
|
|
51
|
+
return colors_dict[col]
|
|
52
|
+
# Try base name without _in/_out suffix
|
|
53
|
+
if col.endswith('_in') or col.endswith('_out'):
|
|
54
|
+
base_name = col[:-3] if col.endswith('_in') else col[:-4]
|
|
55
|
+
if base_name in colors_dict and colors_dict[base_name] is not None:
|
|
56
|
+
return colors_dict[base_name]
|
|
57
|
+
# Default color
|
|
58
|
+
return 'lightgray'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _compute_ylim(
|
|
62
|
+
df: pd.DataFrame,
|
|
63
|
+
timeline: tuple[int, int],
|
|
64
|
+
inflow: pd.Series | None = None,
|
|
65
|
+
) -> tuple[float, float]:
|
|
66
|
+
"""Compute y-axis range from a dispatch DataFrame, including line overlays.
|
|
67
|
+
|
|
68
|
+
Accounts for the stacked area, the Curtailed dashed line, and the
|
|
69
|
+
Demand (inflow) solid line so that nothing is clipped.
|
|
70
|
+
"""
|
|
71
|
+
df_slice = df.iloc[timeline[0]:timeline[1]]
|
|
72
|
+
plot_cols = [col for col in df.columns if col != 'Curtailed']
|
|
73
|
+
area_slice = df_slice[plot_cols]
|
|
74
|
+
pos_max = area_slice.clip(lower=0).sum(axis=1).max()
|
|
75
|
+
neg_min = area_slice.clip(upper=0).sum(axis=1).min()
|
|
76
|
+
|
|
77
|
+
# Include Curtailed line
|
|
78
|
+
if 'Curtailed' in df.columns:
|
|
79
|
+
curtailed_max = df_slice['Curtailed'].max()
|
|
80
|
+
curtailed_min = df_slice['Curtailed'].min()
|
|
81
|
+
pos_max = max(pos_max, curtailed_max)
|
|
82
|
+
neg_min = min(neg_min, curtailed_min)
|
|
83
|
+
|
|
84
|
+
# Include Demand (inflow) line
|
|
85
|
+
if inflow is not None and not inflow.empty:
|
|
86
|
+
inflow_slice = inflow.iloc[timeline[0]:timeline[1]]
|
|
87
|
+
pos_max = max(pos_max, inflow_slice.max())
|
|
88
|
+
neg_min = min(neg_min, inflow_slice.min())
|
|
89
|
+
|
|
90
|
+
# Guard against a degenerate range. An empty/all-zero/flat dispatch
|
|
91
|
+
# window yields neg_min == pos_max (or NaN from a max()/min() over no
|
|
92
|
+
# rows), which makes ax.set_ylim() emit the "identical low and high
|
|
93
|
+
# ylims" warning and collapse the axis. Fall back to a small
|
|
94
|
+
# non-degenerate band centred on the value so the (empty) plot still
|
|
95
|
+
# renders cleanly.
|
|
96
|
+
if not (np.isfinite(neg_min) and np.isfinite(pos_max)):
|
|
97
|
+
neg_min, pos_max = 0.0, 0.0
|
|
98
|
+
if pos_max - neg_min < 1e-9:
|
|
99
|
+
center = pos_max
|
|
100
|
+
pad = abs(center) * 0.05 if center != 0.0 else 1.0
|
|
101
|
+
neg_min, pos_max = center - pad, center + pad
|
|
102
|
+
|
|
103
|
+
return (float(neg_min), float(pos_max))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_dispatch_figure(
|
|
107
|
+
df_dispatch: pd.DataFrame,
|
|
108
|
+
inflow_series: pd.Series | None,
|
|
109
|
+
title: str,
|
|
110
|
+
ylabel: str = "MWh/h",
|
|
111
|
+
colors: dict | None = None,
|
|
112
|
+
timeline: tuple[int, int] = (0, 168),
|
|
113
|
+
ylim: tuple[float, float] | None = None,
|
|
114
|
+
break_times: set[str] | None = None,
|
|
115
|
+
) -> plt.Figure | None:
|
|
116
|
+
"""Build a dispatch stacked-area Figure and return it (without saving or closing).
|
|
117
|
+
|
|
118
|
+
Returns None if there's nothing to plot.
|
|
119
|
+
"""
|
|
120
|
+
from flextool.plot_outputs.format_helpers import insert_timeline_breaks
|
|
121
|
+
|
|
122
|
+
if colors is None:
|
|
123
|
+
colors = {}
|
|
124
|
+
|
|
125
|
+
# Auto-assign colors for columns not already in the colors dict
|
|
126
|
+
colors = _auto_assign_node_colors_with_existing(df_dispatch.columns, colors)
|
|
127
|
+
|
|
128
|
+
# Get plot colors for columns (excluding 'Curtailed' which is plotted as line)
|
|
129
|
+
plot_cols = [col for col in df_dispatch.columns if col != 'Curtailed']
|
|
130
|
+
plot_colors = [get_color_for_column(col, colors) for col in plot_cols]
|
|
131
|
+
|
|
132
|
+
# Slice to timeline
|
|
133
|
+
df_plot = df_dispatch.iloc[timeline[0]:timeline[1]]
|
|
134
|
+
|
|
135
|
+
# Insert NaN rows at timeline breaks for visual gaps
|
|
136
|
+
if break_times:
|
|
137
|
+
df_plot = insert_timeline_breaks(df_plot, break_times)
|
|
138
|
+
if inflow_series is not None:
|
|
139
|
+
inflow_series = insert_timeline_breaks(
|
|
140
|
+
inflow_series.iloc[timeline[0]:timeline[1]].to_frame(), break_times
|
|
141
|
+
).iloc[:, 0]
|
|
142
|
+
|
|
143
|
+
# Check if there's anything to plot (area, curtailed, or demand)
|
|
144
|
+
has_area = bool(plot_cols) and not (df_plot[plot_cols].select_dtypes(include='number').abs() < 1e-6).all().all()
|
|
145
|
+
has_curtailed = 'Curtailed' in df_dispatch.columns and (df_plot['Curtailed'].abs() > 1e-6).any()
|
|
146
|
+
has_demand = inflow_series is not None and not inflow_series.empty
|
|
147
|
+
if not has_area and not has_curtailed and not has_demand:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
# Estimate legend width to size figure appropriately
|
|
151
|
+
all_labels = list(plot_cols)
|
|
152
|
+
if has_curtailed:
|
|
153
|
+
all_labels.append('Curtailed')
|
|
154
|
+
if has_demand:
|
|
155
|
+
all_labels.append('Demand')
|
|
156
|
+
max_label_len = max((len(str(label)) for label in all_labels), default=0)
|
|
157
|
+
legend_width_in = max(1.5, max_label_len * 0.08 + 0.6)
|
|
158
|
+
|
|
159
|
+
plot_width = 10
|
|
160
|
+
fig_width = plot_width + legend_width_in + 0.3
|
|
161
|
+
fig = Figure(figsize=(fig_width, 4))
|
|
162
|
+
|
|
163
|
+
# Position axes to leave room for legend on the right
|
|
164
|
+
left_margin = 0.08
|
|
165
|
+
right_margin = (legend_width_in + 0.2) / fig_width
|
|
166
|
+
ax = fig.add_axes([left_margin, 0.15, 1.0 - left_margin - right_margin, 0.75])
|
|
167
|
+
|
|
168
|
+
# Plot area chart (NaN rows create visual gaps at timeline breaks)
|
|
169
|
+
if has_area:
|
|
170
|
+
df_plot[plot_cols].plot.area(
|
|
171
|
+
ax=ax,
|
|
172
|
+
stacked=True,
|
|
173
|
+
linewidth=0,
|
|
174
|
+
color=plot_colors,
|
|
175
|
+
legend=False
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Use integer x-positions for line overlays so NaN gap rows create visible gaps
|
|
179
|
+
# (matching the area chart's internal integer positioning)
|
|
180
|
+
x_positions = np.arange(len(df_plot))
|
|
181
|
+
|
|
182
|
+
# Plot curtailed as dashed line
|
|
183
|
+
if has_curtailed:
|
|
184
|
+
curtailed = df_plot['Curtailed']
|
|
185
|
+
ax.plot(x_positions, curtailed.values, linestyle='--', color='red', linewidth=1, label='Curtailed')
|
|
186
|
+
|
|
187
|
+
# Plot demand line
|
|
188
|
+
if has_demand:
|
|
189
|
+
if break_times:
|
|
190
|
+
inflow_plot = inflow_series
|
|
191
|
+
else:
|
|
192
|
+
inflow_plot = inflow_series.iloc[timeline[0]:timeline[1]]
|
|
193
|
+
ax.plot(np.arange(len(inflow_plot)), inflow_plot.values, linestyle='solid', color='black', linewidth=1.5, label='Demand')
|
|
194
|
+
|
|
195
|
+
ax.axhline(y=0, color='black', linestyle=':', linewidth=0.5)
|
|
196
|
+
|
|
197
|
+
ax.set_ylabel(ylabel)
|
|
198
|
+
ax.set_title(title)
|
|
199
|
+
|
|
200
|
+
if ylim:
|
|
201
|
+
ax.set_ylim(ylim)
|
|
202
|
+
|
|
203
|
+
# Legend — read top-to-bottom to match the VISUAL stack. The area draw
|
|
204
|
+
# order is neg-block (stacks down, first = nearest axis = top of the
|
|
205
|
+
# negative area) then pos-block (stacks up, first = nearest axis = bottom
|
|
206
|
+
# of the positive area). A blanket reversal makes positives read
|
|
207
|
+
# top-to-bottom but leaves negatives upside-down, so build the order per
|
|
208
|
+
# sign: positives reversed (top-most positive first), negatives as-is
|
|
209
|
+
# (top-most negative first), with the line overlays (Demand / Curtailed) on
|
|
210
|
+
# top.
|
|
211
|
+
handles, labels = ax.get_legend_handles_labels()
|
|
212
|
+
line_labels = {'Curtailed', 'Demand'}
|
|
213
|
+
pos_h, neg_h, line_h = [], [], []
|
|
214
|
+
for h, lbl in zip(handles, labels):
|
|
215
|
+
if lbl in line_labels or lbl not in df_plot.columns:
|
|
216
|
+
line_h.append((h, lbl))
|
|
217
|
+
elif (df_plot[lbl].fillna(0) < 0).any():
|
|
218
|
+
neg_h.append((h, lbl))
|
|
219
|
+
else:
|
|
220
|
+
pos_h.append((h, lbl))
|
|
221
|
+
ordered = line_h + pos_h[::-1] + neg_h
|
|
222
|
+
ax.legend(
|
|
223
|
+
[h for h, _ in ordered], [lbl for _, lbl in ordered],
|
|
224
|
+
bbox_to_anchor=(1.05, 1), loc='upper left',
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return fig
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def plot_dispatch_area(
|
|
231
|
+
df_dispatch: pd.DataFrame,
|
|
232
|
+
inflow_series: pd.Series | None,
|
|
233
|
+
output_path: str | Path,
|
|
234
|
+
title: str,
|
|
235
|
+
ylabel: str = "MWh/h",
|
|
236
|
+
colors: dict | None = None,
|
|
237
|
+
timeline: tuple[int, int] = (0, 168),
|
|
238
|
+
show_plot: bool = False,
|
|
239
|
+
ylim: tuple[float, float] | None = None,
|
|
240
|
+
break_times: set[str] | None = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Create a stacked area dispatch plot with demand line."""
|
|
243
|
+
fig = _build_dispatch_figure(
|
|
244
|
+
df_dispatch, inflow_series, title,
|
|
245
|
+
ylabel=ylabel, colors=colors, timeline=timeline,
|
|
246
|
+
ylim=ylim, break_times=break_times,
|
|
247
|
+
)
|
|
248
|
+
if fig is None:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
# Save (bbox_inches='tight' in savefig handles layout)
|
|
252
|
+
output_path = Path(output_path)
|
|
253
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
fig.savefig(output_path, format='png', bbox_inches='tight', dpi=150)
|
|
255
|
+
|
|
256
|
+
if show_plot:
|
|
257
|
+
plt.show()
|
|
258
|
+
plt.close(fig)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def compute_dispatch_metadata_for_scenario(
|
|
262
|
+
results: TimeSeriesResults,
|
|
263
|
+
mappings: DispatchMappings,
|
|
264
|
+
scenario: str,
|
|
265
|
+
timeline: tuple[int, int] = (0, 168),
|
|
266
|
+
) -> dict:
|
|
267
|
+
"""Compute dispatch ylims + column order for ONE scenario.
|
|
268
|
+
|
|
269
|
+
Returns a dict suitable for JSON serialization::
|
|
270
|
+
|
|
271
|
+
{
|
|
272
|
+
"nodeGroups": {
|
|
273
|
+
"GroupName": {
|
|
274
|
+
"ylim": [ymin, ymax],
|
|
275
|
+
"columns": ["col1", "col2", ...]
|
|
276
|
+
},
|
|
277
|
+
...
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
No margin is applied here; downstream union code adds it. Call sites
|
|
282
|
+
persist this to ``<project>/output_parquet/<scenario>/_dispatch_metadata.json``.
|
|
283
|
+
"""
|
|
284
|
+
dispatch_groups_df = mappings.dispatch_groups
|
|
285
|
+
node_groups: list[str] = []
|
|
286
|
+
if dispatch_groups_df is not None and not dispatch_groups_df.empty:
|
|
287
|
+
node_groups = list(dispatch_groups_df['group'].unique())
|
|
288
|
+
|
|
289
|
+
meta: dict = {"nodeGroups": {}}
|
|
290
|
+
for ng in node_groups:
|
|
291
|
+
df_dispatch, inflow = prepare_dispatch_data(
|
|
292
|
+
results, mappings, scenario, ng,
|
|
293
|
+
)
|
|
294
|
+
if df_dispatch is not None and not df_dispatch.empty:
|
|
295
|
+
ymin, ymax = _compute_ylim(df_dispatch, timeline, inflow)
|
|
296
|
+
meta["nodeGroups"][ng] = {
|
|
297
|
+
"ylim": [float(ymin), float(ymax)],
|
|
298
|
+
"columns": [str(c) for c in df_dispatch.columns],
|
|
299
|
+
}
|
|
300
|
+
return meta
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _fold_per_scenario_metadata(
|
|
304
|
+
per_scen_meta: dict,
|
|
305
|
+
ng_ylims: dict[str, tuple[float, float]],
|
|
306
|
+
ng_columns: dict[str, list[str]],
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Fold a per-scenario metadata dict into running ylims/columns dicts.
|
|
309
|
+
|
|
310
|
+
Mutates *ng_ylims* and *ng_columns* in place with the union (min/max
|
|
311
|
+
ylim, columns extended in first-seen order). No margin is applied
|
|
312
|
+
here — callers add the margin once after folding all scenarios.
|
|
313
|
+
"""
|
|
314
|
+
for ng, entry in per_scen_meta.get("nodeGroups", {}).items():
|
|
315
|
+
ylim = entry.get("ylim")
|
|
316
|
+
columns = entry.get("columns") or []
|
|
317
|
+
if not ylim or len(ylim) < 2:
|
|
318
|
+
continue
|
|
319
|
+
ymin, ymax = float(ylim[0]), float(ylim[1])
|
|
320
|
+
if ng in ng_ylims:
|
|
321
|
+
ng_ylims[ng] = (
|
|
322
|
+
min(ng_ylims[ng][0], ymin),
|
|
323
|
+
max(ng_ylims[ng][1], ymax),
|
|
324
|
+
)
|
|
325
|
+
for col in columns:
|
|
326
|
+
if col not in ng_columns[ng]:
|
|
327
|
+
ng_columns[ng].append(col)
|
|
328
|
+
else:
|
|
329
|
+
ng_ylims[ng] = (ymin, ymax)
|
|
330
|
+
ng_columns[ng] = list(columns)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def compute_dispatch_metadata(
|
|
334
|
+
results: TimeSeriesResults,
|
|
335
|
+
mappings: DispatchMappings,
|
|
336
|
+
scenarios: list[str],
|
|
337
|
+
timeline: tuple[int, int] = (0, 168),
|
|
338
|
+
) -> dict:
|
|
339
|
+
"""Compute cross-scenario ylims and column order per nodeGroup.
|
|
340
|
+
|
|
341
|
+
Returns a dict suitable for JSON serialization::
|
|
342
|
+
|
|
343
|
+
{
|
|
344
|
+
"nodeGroups": {
|
|
345
|
+
"GroupName": {
|
|
346
|
+
"ylim": [ymin, ymax],
|
|
347
|
+
"columns": ["col1", "col2", ...]
|
|
348
|
+
},
|
|
349
|
+
...
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
"""
|
|
353
|
+
ng_ylims: dict[str, tuple[float, float]] = {}
|
|
354
|
+
ng_columns: dict[str, list[str]] = {}
|
|
355
|
+
|
|
356
|
+
for scenario in scenarios:
|
|
357
|
+
per_scen = compute_dispatch_metadata_for_scenario(
|
|
358
|
+
results, mappings, scenario, timeline,
|
|
359
|
+
)
|
|
360
|
+
_fold_per_scenario_metadata(per_scen, ng_ylims, ng_columns)
|
|
361
|
+
|
|
362
|
+
# Add margin
|
|
363
|
+
for key, (ymin, ymax) in ng_ylims.items():
|
|
364
|
+
margin = (ymax - ymin) * 0.05
|
|
365
|
+
ng_ylims[key] = (ymin - margin, ymax + margin)
|
|
366
|
+
|
|
367
|
+
meta: dict = {"nodeGroups": {}}
|
|
368
|
+
for ng, ylim in ng_ylims.items():
|
|
369
|
+
meta["nodeGroups"][ng] = {
|
|
370
|
+
"ylim": list(ylim),
|
|
371
|
+
"columns": ng_columns.get(ng, []),
|
|
372
|
+
}
|
|
373
|
+
return meta
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def union_dispatch_metadata(
|
|
377
|
+
project_path: Path,
|
|
378
|
+
scenarios: list[str],
|
|
379
|
+
margin_fraction: float = 0.05,
|
|
380
|
+
) -> dict:
|
|
381
|
+
"""Union per-scenario _dispatch_metadata.json files into a single
|
|
382
|
+
cross-scenario manifest.
|
|
383
|
+
|
|
384
|
+
Reads ``<project>/output_parquet/<scenario>/_dispatch_metadata.json``
|
|
385
|
+
for each scenario in *scenarios*; missing files contribute nothing
|
|
386
|
+
(silently skipped, like Phase C's availability union).
|
|
387
|
+
|
|
388
|
+
Returns a dict matching the same schema as ``compute_dispatch_metadata``
|
|
389
|
+
(with the 5% ylim margin applied at the union level), or
|
|
390
|
+
``{"nodeGroups": {}}`` if no per-scenario files exist.
|
|
391
|
+
"""
|
|
392
|
+
import json as _json
|
|
393
|
+
|
|
394
|
+
project_path = Path(project_path)
|
|
395
|
+
ng_ylims: dict[str, tuple[float, float]] = {}
|
|
396
|
+
ng_columns: dict[str, list[str]] = {}
|
|
397
|
+
|
|
398
|
+
for scenario in scenarios:
|
|
399
|
+
meta_path = (
|
|
400
|
+
project_path / "output_parquet" / scenario / "_dispatch_metadata.json"
|
|
401
|
+
)
|
|
402
|
+
if not meta_path.is_file():
|
|
403
|
+
continue
|
|
404
|
+
try:
|
|
405
|
+
with open(meta_path, "r", encoding="utf-8") as f:
|
|
406
|
+
per_scen = _json.load(f)
|
|
407
|
+
except (OSError, _json.JSONDecodeError):
|
|
408
|
+
continue
|
|
409
|
+
if not isinstance(per_scen, dict):
|
|
410
|
+
continue
|
|
411
|
+
_fold_per_scenario_metadata(per_scen, ng_ylims, ng_columns)
|
|
412
|
+
|
|
413
|
+
# Add margin at the union level
|
|
414
|
+
for key, (ymin, ymax) in ng_ylims.items():
|
|
415
|
+
margin = (ymax - ymin) * margin_fraction
|
|
416
|
+
ng_ylims[key] = (ymin - margin, ymax + margin)
|
|
417
|
+
|
|
418
|
+
meta: dict = {"nodeGroups": {}}
|
|
419
|
+
for ng, ylim in ng_ylims.items():
|
|
420
|
+
meta["nodeGroups"][ng] = {
|
|
421
|
+
"ylim": list(ylim),
|
|
422
|
+
"columns": ng_columns.get(ng, []),
|
|
423
|
+
}
|
|
424
|
+
return meta
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def create_dispatch_plots(
|
|
428
|
+
results: TimeSeriesResults,
|
|
429
|
+
mappings: DispatchMappings,
|
|
430
|
+
plot_dir: str | Path,
|
|
431
|
+
scenarios: list[str],
|
|
432
|
+
show_plot: bool = False,
|
|
433
|
+
write_xlsx: bool = False,
|
|
434
|
+
plot_rows: list[int] | tuple[int, int] | None = None,
|
|
435
|
+
break_times: set[str] | None = None,
|
|
436
|
+
debug: bool = False,
|
|
437
|
+
settings_path: str | Path | None = None,
|
|
438
|
+
) -> None:
|
|
439
|
+
"""Create dispatch plots for all nodeGroups (and, in debug, all nodes).
|
|
440
|
+
|
|
441
|
+
nodeGroup dispatch plots are always generated. Per-node dispatch plots
|
|
442
|
+
are generated for ALL nodes that have dispatch data (discovered from the
|
|
443
|
+
results, never curated) ONLY when *debug* is true; otherwise no node
|
|
444
|
+
plots are produced. There is no persisted node selection.
|
|
445
|
+
|
|
446
|
+
*scenarios* is the run's scenario set (the data-derived dispatch tags),
|
|
447
|
+
passed in by the caller; there is no longer a ``config.yaml`` fallback.
|
|
448
|
+
|
|
449
|
+
*settings_path* is the already-resolved ``plot_settings.yaml`` path (the
|
|
450
|
+
orchestrator resolves it against the derived project root). When omitted
|
|
451
|
+
it is resolved from ``plot_dir.parent`` — the legacy assumption that the
|
|
452
|
+
comparison output dir lives directly under the project root.
|
|
453
|
+
"""
|
|
454
|
+
plot_dir = Path(plot_dir)
|
|
455
|
+
|
|
456
|
+
# Dispatch colors + stacking order now come from the project's
|
|
457
|
+
# ``plot_settings.yaml`` (``entities`` for per-entity flows, new
|
|
458
|
+
# ``categories.dispatch`` block for the special tokens), resolved by
|
|
459
|
+
# ``resolve_dispatch_colors_and_order``. This replaces the legacy
|
|
460
|
+
# ``config['positive'|'negative']`` parsing. The template path is the
|
|
461
|
+
# caller-supplied *settings_path* (resolved against the derived project
|
|
462
|
+
# root) or, as a fallback, resolved from the parent of *plot_dir*.
|
|
463
|
+
#
|
|
464
|
+
# ``colors`` / ``config_order`` keep the same shape as before so the
|
|
465
|
+
# downstream ``prepare_dispatch_data`` → ``_order_dispatch_columns`` →
|
|
466
|
+
# render interface is unchanged. ``colors`` is resolved once the union
|
|
467
|
+
# of actual dispatch columns is known (after the first / ylim pass);
|
|
468
|
+
# special-token colors and the entity ordering do not depend on the
|
|
469
|
+
# data, only the per-column color resolution does.
|
|
470
|
+
from flextool.plot_outputs.color_template import (
|
|
471
|
+
load_color_template,
|
|
472
|
+
resolve_dispatch_colors_and_order,
|
|
473
|
+
resolve_plot_settings_path,
|
|
474
|
+
template_entity_names,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if settings_path is None:
|
|
478
|
+
settings_path = resolve_plot_settings_path(plot_dir.parent)
|
|
479
|
+
template = load_color_template(settings_path)
|
|
480
|
+
|
|
481
|
+
# ``config_order`` (entity stacking order) depends only on the template's
|
|
482
|
+
# entity file order, not on the data, so resolve it once up front and use
|
|
483
|
+
# it identically in the ylim pass and the plot pass. ``colors`` needs the
|
|
484
|
+
# actual column names (composite / node-level columns resolve to an entity
|
|
485
|
+
# name), so it is resolved after the union of columns is known (below).
|
|
486
|
+
# Resolving config_order from the template entity names keeps the two
|
|
487
|
+
# passes byte-consistent; per-column colors are filled in afterwards.
|
|
488
|
+
_, config_order = resolve_dispatch_colors_and_order(
|
|
489
|
+
template, template_entity_names(template),
|
|
490
|
+
)
|
|
491
|
+
colors: dict[str, str] = {}
|
|
492
|
+
|
|
493
|
+
# Time window comes from ``plot_rows`` (GUI/CLI, derived from
|
|
494
|
+
# ``PlotSettings.start_time``/``duration``); a sane default covers the
|
|
495
|
+
# rare path where the caller omits it (the old ``config.yaml``
|
|
496
|
+
# ``time_to_plot`` source is gone).
|
|
497
|
+
if plot_rows and len(plot_rows) >= 2:
|
|
498
|
+
timeline = (int(plot_rows[0]), int(plot_rows[1]) + 1) # plot_rows is inclusive
|
|
499
|
+
else:
|
|
500
|
+
timeline = (0, 168)
|
|
501
|
+
|
|
502
|
+
# Get nodeGroups from data (dispatch_groups mapping), not config
|
|
503
|
+
dispatch_groups_df = mappings.dispatch_groups
|
|
504
|
+
node_groups: list[str] = []
|
|
505
|
+
if dispatch_groups_df is not None and not dispatch_groups_df.empty:
|
|
506
|
+
node_groups = list(dispatch_groups_df['group'].unique())
|
|
507
|
+
|
|
508
|
+
excel_data: dict[str, pd.DataFrame] = {}
|
|
509
|
+
|
|
510
|
+
# First pass: collect y-axis ranges + column order. ylims are keyed
|
|
511
|
+
# PER (scenario, group/node) so each plot scales to its own scenario's
|
|
512
|
+
# dispatch — unioning ylims across scenarios of very different magnitude
|
|
513
|
+
# (e.g. a represented-period solve vs an 8760 h trade-only snapshot
|
|
514
|
+
# ~1000x larger) would force the smaller scenario onto the larger one's
|
|
515
|
+
# axis and flatten it. Columns stay keyed by group/node (unioned across
|
|
516
|
+
# scenarios) so the legend / stacking order is consistent between plots.
|
|
517
|
+
ng_ylims: dict[tuple[str, str], tuple[float, float]] = {}
|
|
518
|
+
node_ylims: dict[tuple[str, str], tuple[float, float]] = {}
|
|
519
|
+
ng_columns: dict[str, list[str]] = {}
|
|
520
|
+
node_columns: dict[str, list[str]] = {}
|
|
521
|
+
# No node curation: ``config['nodes']`` is no longer consumed. Per-node
|
|
522
|
+
# dispatch is a debug-only convenience covering ALL nodes that have
|
|
523
|
+
# dispatch data (discovered from the results), and is skipped entirely
|
|
524
|
+
# when ``debug`` is false.
|
|
525
|
+
from flextool.scenario_comparison.dispatch_data import (
|
|
526
|
+
available_dispatch_nodes,
|
|
527
|
+
)
|
|
528
|
+
nodes = available_dispatch_nodes(results) if debug else []
|
|
529
|
+
|
|
530
|
+
for scenario in scenarios:
|
|
531
|
+
for ng in node_groups:
|
|
532
|
+
df_dispatch, inflow = prepare_dispatch_data(
|
|
533
|
+
results, mappings, scenario, ng,
|
|
534
|
+
colors=colors, config_order=config_order,
|
|
535
|
+
)
|
|
536
|
+
if df_dispatch is not None and not df_dispatch.empty:
|
|
537
|
+
ymin, ymax = _compute_ylim(df_dispatch, timeline, inflow)
|
|
538
|
+
# ylim: per (scenario, group) — each plot scales to itself.
|
|
539
|
+
ng_ylims[(scenario, ng)] = (ymin, ymax)
|
|
540
|
+
# columns: unioned per group for consistent legend/stacking.
|
|
541
|
+
if ng in ng_columns:
|
|
542
|
+
for col in df_dispatch.columns:
|
|
543
|
+
if col not in ng_columns[ng]:
|
|
544
|
+
ng_columns[ng].append(col)
|
|
545
|
+
else:
|
|
546
|
+
ng_columns[ng] = list(df_dispatch.columns)
|
|
547
|
+
|
|
548
|
+
for node in nodes:
|
|
549
|
+
df_node, inflow_node = prepare_node_dispatch_data(
|
|
550
|
+
results, scenario, node
|
|
551
|
+
)
|
|
552
|
+
if df_node is not None and not df_node.empty:
|
|
553
|
+
ymin, ymax = _compute_ylim(df_node, timeline, inflow_node)
|
|
554
|
+
node_ylims[(scenario, node)] = (ymin, ymax)
|
|
555
|
+
if node in node_columns:
|
|
556
|
+
for col in df_node.columns:
|
|
557
|
+
if col not in node_columns[node]:
|
|
558
|
+
node_columns[node].append(col)
|
|
559
|
+
else:
|
|
560
|
+
node_columns[node] = list(df_node.columns)
|
|
561
|
+
|
|
562
|
+
# Add small margin to y-axis limits
|
|
563
|
+
for key, (ymin, ymax) in ng_ylims.items():
|
|
564
|
+
margin = (ymax - ymin) * 0.05
|
|
565
|
+
ng_ylims[key] = (ymin - margin, ymax + margin)
|
|
566
|
+
for key, (ymin, ymax) in node_ylims.items():
|
|
567
|
+
margin = (ymax - ymin) * 0.05
|
|
568
|
+
node_ylims[key] = (ymin - margin, ymax + margin)
|
|
569
|
+
|
|
570
|
+
# Resolve per-column colors now that the union of dispatch columns
|
|
571
|
+
# (nodeGroup + node, base + split parts) is known. Special tokens map to
|
|
572
|
+
# ``categories.dispatch`` (== the old DEFAULT_SPECIAL_COLORS); entity /
|
|
573
|
+
# composite / node-level columns map to ``entities``; anything unresolved
|
|
574
|
+
# is left to ``_auto_assign_node_colors_with_existing``'s palette below.
|
|
575
|
+
union_columns: list[str] = []
|
|
576
|
+
seen_cols: set[str] = set()
|
|
577
|
+
for col_list in list(ng_columns.values()) + list(node_columns.values()):
|
|
578
|
+
for col in col_list:
|
|
579
|
+
scol = str(col)
|
|
580
|
+
if scol not in seen_cols:
|
|
581
|
+
seen_cols.add(scol)
|
|
582
|
+
union_columns.append(scol)
|
|
583
|
+
resolved_colors, _ = resolve_dispatch_colors_and_order(template, union_columns)
|
|
584
|
+
colors = resolved_colors
|
|
585
|
+
|
|
586
|
+
for scenario in scenarios:
|
|
587
|
+
print(f"Creating dispatch plots for scenario: {scenario}")
|
|
588
|
+
|
|
589
|
+
# Plot nodeGroup dispatches
|
|
590
|
+
for ng in node_groups:
|
|
591
|
+
df_dispatch, inflow = prepare_dispatch_data(
|
|
592
|
+
results, mappings, scenario, ng,
|
|
593
|
+
colors=colors, config_order=config_order,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
has_dispatch = df_dispatch is not None and not df_dispatch.empty
|
|
597
|
+
has_demand = inflow is not None and not inflow.empty
|
|
598
|
+
if has_dispatch or has_demand:
|
|
599
|
+
if df_dispatch is None or df_dispatch.empty:
|
|
600
|
+
# Create a minimal DataFrame so plot_dispatch_area can
|
|
601
|
+
# draw the demand line even when there are no supply flows.
|
|
602
|
+
idx = inflow.index if has_demand else pd.RangeIndex(1)
|
|
603
|
+
df_dispatch = pd.DataFrame(index=idx)
|
|
604
|
+
# Ensure consistent columns across scenarios for same nodeGroup.
|
|
605
|
+
# Build the missing columns in one DataFrame and concat once;
|
|
606
|
+
# the per-column ``df_dispatch[col] = 0.0`` loop triggered a
|
|
607
|
+
# pandas PerformanceWarning about frame fragmentation when
|
|
608
|
+
# ng_columns[ng] had many entries.
|
|
609
|
+
if ng in ng_columns:
|
|
610
|
+
missing = [
|
|
611
|
+
c for c in ng_columns[ng]
|
|
612
|
+
if c not in df_dispatch.columns
|
|
613
|
+
]
|
|
614
|
+
if missing:
|
|
615
|
+
zeros = pd.DataFrame(
|
|
616
|
+
0.0, index=df_dispatch.index, columns=missing,
|
|
617
|
+
)
|
|
618
|
+
df_dispatch = pd.concat(
|
|
619
|
+
[df_dispatch, zeros], axis=1,
|
|
620
|
+
)
|
|
621
|
+
df_dispatch = df_dispatch[ng_columns[ng]]
|
|
622
|
+
output_path = plot_dir / f"dispatch_nodeGroup_{ng}_{scenario}.png"
|
|
623
|
+
plot_dispatch_area(
|
|
624
|
+
df_dispatch, inflow, output_path,
|
|
625
|
+
title=f"{ng} - {scenario}",
|
|
626
|
+
colors=colors,
|
|
627
|
+
timeline=timeline,
|
|
628
|
+
show_plot=show_plot,
|
|
629
|
+
ylim=ng_ylims.get((scenario, ng)),
|
|
630
|
+
break_times=break_times,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
if write_xlsx:
|
|
634
|
+
excel_data[f"{ng}_{scenario}"] = df_dispatch
|
|
635
|
+
else:
|
|
636
|
+
print(f" No dispatch data for nodeGroup {ng}")
|
|
637
|
+
|
|
638
|
+
# Plot individual node dispatches
|
|
639
|
+
for node in nodes:
|
|
640
|
+
df_node, inflow_node = prepare_node_dispatch_data(
|
|
641
|
+
results, scenario, node
|
|
642
|
+
)
|
|
643
|
+
has_node_dispatch = df_node is not None and not df_node.empty
|
|
644
|
+
has_node_demand = inflow_node is not None and not inflow_node.empty
|
|
645
|
+
if has_node_dispatch or has_node_demand:
|
|
646
|
+
if df_node is None or df_node.empty:
|
|
647
|
+
idx = inflow_node.index if has_node_demand else pd.RangeIndex(1)
|
|
648
|
+
df_node = pd.DataFrame(index=idx)
|
|
649
|
+
# Ensure consistent columns across scenarios for same node.
|
|
650
|
+
# Same fragmentation fix as the nodeGroup branch above —
|
|
651
|
+
# build missing zero-columns in one DataFrame + concat once
|
|
652
|
+
# instead of per-column ``df_node[col] = 0.0`` assignment.
|
|
653
|
+
if node in node_columns:
|
|
654
|
+
missing = [
|
|
655
|
+
c for c in node_columns[node]
|
|
656
|
+
if c not in df_node.columns
|
|
657
|
+
]
|
|
658
|
+
if missing:
|
|
659
|
+
zeros = pd.DataFrame(
|
|
660
|
+
0.0, index=df_node.index, columns=missing,
|
|
661
|
+
)
|
|
662
|
+
df_node = pd.concat([df_node, zeros], axis=1)
|
|
663
|
+
df_node = df_node[node_columns[node]]
|
|
664
|
+
# Seed with template-resolved colors (entity / special
|
|
665
|
+
# tokens), then auto-assign palette colors for the rest.
|
|
666
|
+
node_colors = _auto_assign_node_colors_with_existing(
|
|
667
|
+
df_node.columns, colors,
|
|
668
|
+
)
|
|
669
|
+
output_path = plot_dir / f"dispatch_node_{node}_{scenario}.png"
|
|
670
|
+
plot_dispatch_area(
|
|
671
|
+
df_node, inflow_node, output_path,
|
|
672
|
+
title=f"{node} - {scenario}",
|
|
673
|
+
colors=node_colors,
|
|
674
|
+
timeline=timeline,
|
|
675
|
+
show_plot=show_plot,
|
|
676
|
+
ylim=node_ylims.get((scenario, node)),
|
|
677
|
+
break_times=break_times,
|
|
678
|
+
)
|
|
679
|
+
if write_xlsx:
|
|
680
|
+
excel_data[f"node_{node}_{scenario}"] = df_node
|
|
681
|
+
else:
|
|
682
|
+
print(f" No dispatch data for node {node}")
|
|
683
|
+
|
|
684
|
+
# Write Excel file
|
|
685
|
+
if write_xlsx and excel_data:
|
|
686
|
+
excel_path = plot_dir / "dispatch_data.xlsx"
|
|
687
|
+
with pd.ExcelWriter(excel_path, engine='xlsxwriter') as writer:
|
|
688
|
+
for name, df in excel_data.items():
|
|
689
|
+
sheet_name = name[:31] # Excel sheet name limit
|
|
690
|
+
df.to_excel(writer, sheet_name=sheet_name)
|
|
691
|
+
print(f"Wrote dispatch data to {excel_path}")
|