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,475 @@
|
|
|
1
|
+
"""Output functions for node group results."""
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
from flextool.process_outputs._annualize import annualize_dt_to_d
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_group_nodes_with_inflow(s, g: str) -> list[str]:
|
|
8
|
+
"""Return nodes in group g that have an inflow method (not 'no_inflow')."""
|
|
9
|
+
group_nodes = (
|
|
10
|
+
s.group_node[s.group_node.get_level_values('group') == g]
|
|
11
|
+
.get_level_values('node')
|
|
12
|
+
.tolist()
|
|
13
|
+
)
|
|
14
|
+
if hasattr(s, 'node__inflow_method'):
|
|
15
|
+
no_inflow_nodes = {n for (n, method) in s.node__inflow_method if method == 'no_inflow'}
|
|
16
|
+
return [n for n in group_nodes if n not in no_inflow_nodes]
|
|
17
|
+
return group_nodes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def nodeGroup_indicators(par, s, v, r, debug):
|
|
21
|
+
"""Node group indicator results by period and time"""
|
|
22
|
+
|
|
23
|
+
results = []
|
|
24
|
+
|
|
25
|
+
if not list(s.nodeGroupIndicators):
|
|
26
|
+
return results
|
|
27
|
+
|
|
28
|
+
# Get time steps
|
|
29
|
+
dt_index = s.dt_realize_dispatch # Should be MultiIndex with (period, time)
|
|
30
|
+
|
|
31
|
+
# Calculate timestep-level results first
|
|
32
|
+
results_dt = []
|
|
33
|
+
|
|
34
|
+
for g in s.nodeGroupIndicators:
|
|
35
|
+
group_nodes = (
|
|
36
|
+
s.group_node[s.group_node.get_level_values('group').isin([g])]
|
|
37
|
+
.get_level_values('node')
|
|
38
|
+
.tolist()
|
|
39
|
+
)
|
|
40
|
+
group_nodes_with_inflow = _get_group_nodes_with_inflow(s, g)
|
|
41
|
+
|
|
42
|
+
# 1. pdtNodeInflow (negative sum)
|
|
43
|
+
if group_nodes_with_inflow:
|
|
44
|
+
group_inflow = -par.node_inflow[group_nodes_with_inflow].sum(axis=1)
|
|
45
|
+
else:
|
|
46
|
+
group_inflow = pd.Series(0, index=dt_index)
|
|
47
|
+
|
|
48
|
+
# 2. Sum of annualized inflows [MWh]. Weight each (d, t) by
|
|
49
|
+
# par.timestep_weight (=1.0 with no/uniform timeset_weights →
|
|
50
|
+
# byte-identical) so the annualized inflow matches the cost-weighted
|
|
51
|
+
# objective. group_inflow itself stays unweighted: it feeds the
|
|
52
|
+
# intensive VRE/slack *shares* below where the weight would cancel
|
|
53
|
+
# anyway, and the raw '5. Timestep inflow' output.
|
|
54
|
+
annualized_inflow = group_inflow.mul(par.timestep_weight, axis=0).div(par.complete_period_share_of_year)
|
|
55
|
+
|
|
56
|
+
# Flow-derived quantities (r.flow_dt, potentialVREgen_dt) and q_state
|
|
57
|
+
# slacks are MW at the timestep. Multiply by step_duration so the
|
|
58
|
+
# stored timestep value is MWh/step — matching '5. Timestep inflow'
|
|
59
|
+
# (par.node_inflow is already MWh/step). This keeps all columns in
|
|
60
|
+
# consistent energy units so shares and period aggregations work for
|
|
61
|
+
# any step_duration.
|
|
62
|
+
step_dur = par.step_duration
|
|
63
|
+
|
|
64
|
+
# 3. VRE share (actual flow)
|
|
65
|
+
vre_processes = s.process_VRE.get_level_values('process')
|
|
66
|
+
vre_cols = r.flow_dt.columns[
|
|
67
|
+
r.flow_dt.columns.get_level_values('sink').isin(group_nodes) &
|
|
68
|
+
r.flow_dt.columns.get_level_values('process').isin(vre_processes) &
|
|
69
|
+
r.flow_dt.columns.isin(s.process_source_sink_alwaysProcess)
|
|
70
|
+
]
|
|
71
|
+
vre_flow_sum = r.flow_dt[vre_cols].mul(step_dur, axis=0).sum(axis=1)
|
|
72
|
+
|
|
73
|
+
# VRE share calculation (avoid division by zero)
|
|
74
|
+
vre_share = vre_flow_sum / group_inflow.where(group_inflow != 0, pd.NA)
|
|
75
|
+
|
|
76
|
+
# 4. Curtailed VRE share
|
|
77
|
+
potential_cols = r.potentialVREgen_dt.columns[
|
|
78
|
+
r.potentialVREgen_dt.columns.get_level_values(1).isin(group_nodes) &
|
|
79
|
+
r.potentialVREgen_dt.columns.get_level_values(0).isin(vre_processes)
|
|
80
|
+
]
|
|
81
|
+
potential_sum = r.potentialVREgen_dt[potential_cols].mul(step_dur, axis=0).sum(axis=1)
|
|
82
|
+
curtailed_vre = (potential_sum - vre_flow_sum).clip(lower=0)
|
|
83
|
+
curtailed_vre_share = curtailed_vre / group_inflow.where(group_inflow != 0, pd.NA)
|
|
84
|
+
curtailed_vre / potential_sum
|
|
85
|
+
|
|
86
|
+
# Filter balance nodes directly from the sets
|
|
87
|
+
balance_set = set(s.node_balance) | set(s.node_balance_period)
|
|
88
|
+
balance_nodes = [n for n in group_nodes if n in balance_set]
|
|
89
|
+
|
|
90
|
+
# 5. Upward slack (MWh/step)
|
|
91
|
+
if balance_nodes and not v.q_state_up.empty:
|
|
92
|
+
# node_capacity_for_scaling is complete over nodeBalance ∪
|
|
93
|
+
# nodeBalancePeriod (⊇ q_state nodes), so direct column indexing
|
|
94
|
+
# is safe.
|
|
95
|
+
upward_slack = v.q_state_up.mul(par.node_capacity_for_scaling[v.q_state_up.columns]).sum(axis=1).clip(lower=0)
|
|
96
|
+
upward_slack = upward_slack.mul(step_dur)
|
|
97
|
+
else:
|
|
98
|
+
upward_slack = pd.Series(0, index=dt_index)
|
|
99
|
+
|
|
100
|
+
# 6. Downward slack (MWh/step)
|
|
101
|
+
if balance_nodes and not v.q_state_down.empty:
|
|
102
|
+
downward_slack = v.q_state_down.mul(par.node_capacity_for_scaling[v.q_state_down.columns]).sum(axis=1).clip(lower=0)
|
|
103
|
+
downward_slack = downward_slack.mul(step_dur)
|
|
104
|
+
else:
|
|
105
|
+
downward_slack = pd.Series(0, index=dt_index)
|
|
106
|
+
|
|
107
|
+
# Multi-solve handoff: v.* / r.flow_dt span the full union of
|
|
108
|
+
# sub-solve dt axes, while ``dt_realize_dispatch`` is the
|
|
109
|
+
# post-roll "realized" subset. Reindex every component series
|
|
110
|
+
# to ``dt_index`` so the per-(period, time) DataFrame below
|
|
111
|
+
# never raises "All arrays must be of the same length".
|
|
112
|
+
upward_slack = upward_slack.reindex(dt_index, fill_value=0)
|
|
113
|
+
downward_slack = downward_slack.reindex(dt_index, fill_value=0)
|
|
114
|
+
vre_flow_sum = vre_flow_sum.reindex(dt_index, fill_value=0)
|
|
115
|
+
curtailed_vre = curtailed_vre.reindex(dt_index, fill_value=0)
|
|
116
|
+
curtailed_vre_share = curtailed_vre_share.reindex(dt_index, fill_value=0)
|
|
117
|
+
vre_share = vre_share.reindex(dt_index, fill_value=0)
|
|
118
|
+
group_inflow = group_inflow.reindex(dt_index, fill_value=0)
|
|
119
|
+
annualized_inflow = annualized_inflow.reindex(dt_index, fill_value=0)
|
|
120
|
+
|
|
121
|
+
# Combine timestep results for this group
|
|
122
|
+
group_result_dt = pd.DataFrame({
|
|
123
|
+
'group': g,
|
|
124
|
+
'period': dt_index.get_level_values('period'),
|
|
125
|
+
'time': dt_index.get_level_values('time'),
|
|
126
|
+
'1. Loss of load': upward_slack.fillna(0).values,
|
|
127
|
+
'2. VRE generation': vre_flow_sum.values,
|
|
128
|
+
'3. Excess load': downward_slack.fillna(0).values,
|
|
129
|
+
'4. Curtailed VRE': curtailed_vre.values,
|
|
130
|
+
'5. Timestep inflow': group_inflow.values,
|
|
131
|
+
'6. Curtailed VRE of potential VRE': curtailed_vre_share.fillna(0).values,
|
|
132
|
+
'7. Annualized inflow': annualized_inflow.values,
|
|
133
|
+
'8. VRE share of demand': vre_share.fillna(0).values,
|
|
134
|
+
})
|
|
135
|
+
results_dt.append(group_result_dt)
|
|
136
|
+
|
|
137
|
+
# Combine all groups for timestep level
|
|
138
|
+
result_flat_dt = pd.concat(results_dt, ignore_index=True)
|
|
139
|
+
|
|
140
|
+
# Create multi-index version for timestep level
|
|
141
|
+
results_dt_indexed = result_flat_dt.set_index(['group', 'period', 'time'])[
|
|
142
|
+
['1. Loss of load', '2. VRE generation', '3. Excess load', '4. Curtailed VRE',
|
|
143
|
+
'5. Timestep inflow', '6. Curtailed VRE of potential VRE', '7. Annualized inflow', '8. VRE share of demand']
|
|
144
|
+
]
|
|
145
|
+
results_dt_indexed.columns.name = "parameter"
|
|
146
|
+
|
|
147
|
+
results.append((results_dt_indexed, 'nodeGroup_gdt_p'))
|
|
148
|
+
|
|
149
|
+
# Aggregate to period level
|
|
150
|
+
# For shares, we need to recalculate properly:
|
|
151
|
+
# Sum the numerators and denominators separately, then divide
|
|
152
|
+
inflow_d = results_dt_indexed['5. Timestep inflow'].groupby(level=['group', 'period']).sum()
|
|
153
|
+
annualized_inflow_d = results_dt_indexed['7. Annualized inflow'].groupby(level=['group', 'period']).sum()
|
|
154
|
+
|
|
155
|
+
# For VRE share: need to sum absolute flows and recalculate
|
|
156
|
+
# Since we have vre_share * inflow = vre_flow, we can recover vre_flow
|
|
157
|
+
vre_flow_dt = results_dt_indexed['8. VRE share of demand'] * results_dt_indexed['5. Timestep inflow']
|
|
158
|
+
vre_flow_d = vre_flow_dt.groupby(level=['group', 'period']).sum()
|
|
159
|
+
vre_share_d = vre_flow_d / inflow_d.where(inflow_d != 0, pd.NA)
|
|
160
|
+
|
|
161
|
+
# For curtailed VRE share: similar recovery
|
|
162
|
+
curtailed_vre_share_dt = results_dt_indexed['4. Curtailed VRE'] / results_dt_indexed['5. Timestep inflow']
|
|
163
|
+
curtailed_vre_share_d = curtailed_vre_share_dt.groupby(level=['group', 'period']).mean()
|
|
164
|
+
curtailed_vre_of_potential_vre_d = results_dt_indexed['6. Curtailed VRE of potential VRE'].groupby(level=['group', 'period']).sum()
|
|
165
|
+
|
|
166
|
+
# Slack shares: sum absolute values and recalculate
|
|
167
|
+
upward_slack_d = results_dt_indexed['1. Loss of load'].groupby(['group', 'period']).sum()
|
|
168
|
+
upward_slack_share_d = upward_slack_d / inflow_d
|
|
169
|
+
|
|
170
|
+
downward_slack_d = results_dt_indexed['3. Excess load'].groupby(['group', 'period']).sum()
|
|
171
|
+
downward_slack_share_d = downward_slack_d / inflow_d
|
|
172
|
+
|
|
173
|
+
# Combine period-level results
|
|
174
|
+
results_d_indexed = pd.DataFrame({
|
|
175
|
+
'1. Loss of load share': upward_slack_share_d.fillna(0),
|
|
176
|
+
'2. VRE share of demand': vre_share_d.fillna(0),
|
|
177
|
+
'3. Excess load share': downward_slack_share_d.fillna(0),
|
|
178
|
+
'4. Curtailed VRE of demand': curtailed_vre_share_d.fillna(0),
|
|
179
|
+
'5. Annualized inflow': annualized_inflow_d,
|
|
180
|
+
'6. Curtailed VRE of potential VRE': curtailed_vre_of_potential_vre_d.fillna(0)
|
|
181
|
+
})
|
|
182
|
+
results_d_indexed.columns.name = "parameter"
|
|
183
|
+
|
|
184
|
+
results.append((results_d_indexed, 'nodeGroup_gd_p'))
|
|
185
|
+
|
|
186
|
+
return results
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def nodeGroup_VRE_share(par, s, v, r, debug):
|
|
190
|
+
"""VRE share for node groups by period and time"""
|
|
191
|
+
|
|
192
|
+
results = []
|
|
193
|
+
|
|
194
|
+
# Get timesteps and groups
|
|
195
|
+
timesteps = list(s.dt_realize_dispatch)
|
|
196
|
+
|
|
197
|
+
# Filter groups that have nodes with inflow
|
|
198
|
+
groups_with_inflow = [
|
|
199
|
+
g for g in s.nodeGroupIndicators
|
|
200
|
+
if _get_group_nodes_with_inflow(s, g)
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
if not groups_with_inflow or not timesteps:
|
|
204
|
+
index = pd.MultiIndex.from_tuples([], names=['period', 'time'])
|
|
205
|
+
results_dt = pd.DataFrame(
|
|
206
|
+
index=index,
|
|
207
|
+
columns=pd.Index(groups_with_inflow, name='group'),
|
|
208
|
+
)
|
|
209
|
+
return [(results_dt, 'nodeGroup_VRE_share_dt_g')]
|
|
210
|
+
|
|
211
|
+
# Create index
|
|
212
|
+
index = pd.MultiIndex.from_tuples(timesteps, names=['period', 'time'])
|
|
213
|
+
results_dt = pd.DataFrame(
|
|
214
|
+
index=index,
|
|
215
|
+
columns=pd.Index(groups_with_inflow, name='group'),
|
|
216
|
+
dtype=float,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Get VRE processes
|
|
220
|
+
vre_processes = s.process_VRE.unique()
|
|
221
|
+
|
|
222
|
+
# Calculate for each group
|
|
223
|
+
for g in groups_with_inflow:
|
|
224
|
+
group_nodes = _get_group_nodes_with_inflow(s, g)
|
|
225
|
+
node_cols = [col for col in par.node_inflow.columns.get_level_values(0) if col in group_nodes]
|
|
226
|
+
|
|
227
|
+
# Total inflow to group nodes (vectorized)
|
|
228
|
+
total_inflow = par.node_inflow[node_cols].sum(axis=1)
|
|
229
|
+
|
|
230
|
+
# VRE flow: sum flows from VRE processes to group nodes (vectorized)
|
|
231
|
+
vre_cols = [(p, src, snk) for (p, src, snk) in r.flow_dt.columns
|
|
232
|
+
if p in vre_processes and (p, snk) in s.process_sink and snk in group_nodes]
|
|
233
|
+
|
|
234
|
+
if vre_cols:
|
|
235
|
+
# r.flow_dt is MW — multiply by step_duration to get MWh/step so
|
|
236
|
+
# the share against total_inflow (MWh/step) is a proper ratio.
|
|
237
|
+
vre_flow = r.flow_dt[vre_cols].mul(par.step_duration, axis=0).sum(axis=1)
|
|
238
|
+
else:
|
|
239
|
+
vre_flow = pd.Series(0.0, index=r.flow_dt.index)
|
|
240
|
+
|
|
241
|
+
# Calculate share (avoid division by zero)
|
|
242
|
+
results_dt[g] = (vre_flow / (-total_inflow)).fillna(0.0)
|
|
243
|
+
|
|
244
|
+
results.append((results_dt, 'nodeGroup_VRE_share_dt_g'))
|
|
245
|
+
|
|
246
|
+
# Return period results
|
|
247
|
+
results_d = results_dt.groupby(level='period').mean()
|
|
248
|
+
results_d.columns.name = 'group'
|
|
249
|
+
results.append((results_d, 'nodeGroup_VRE_share_d_g'))
|
|
250
|
+
|
|
251
|
+
return results
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def nodeGroup_total_inflow(par, s, v, r, debug):
|
|
255
|
+
"""Total inflow (par.node_inflow) summed over nodes in each group."""
|
|
256
|
+
|
|
257
|
+
results = []
|
|
258
|
+
|
|
259
|
+
# Use node-based group membership (same as nodeGroup_VRE_share)
|
|
260
|
+
groups = [
|
|
261
|
+
g for g in s.nodeGroupIndicators
|
|
262
|
+
if _get_group_nodes_with_inflow(s, g)
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
if not groups:
|
|
266
|
+
return results
|
|
267
|
+
|
|
268
|
+
timesteps = list(s.dt_realize_dispatch)
|
|
269
|
+
if not timesteps:
|
|
270
|
+
return results
|
|
271
|
+
|
|
272
|
+
# Calculate timestep-level results
|
|
273
|
+
index = pd.MultiIndex.from_tuples(timesteps, names=['period', 'time'])
|
|
274
|
+
result_multi_dt = pd.DataFrame(index=index, columns=groups, dtype=float)
|
|
275
|
+
|
|
276
|
+
for g in groups:
|
|
277
|
+
group_nodes = _get_group_nodes_with_inflow(s, g)
|
|
278
|
+
node_cols = [col for col in par.node_inflow.columns.get_level_values(0)
|
|
279
|
+
if col in group_nodes]
|
|
280
|
+
if node_cols:
|
|
281
|
+
result_multi_dt[g] = par.node_inflow[node_cols].sum(axis=1)
|
|
282
|
+
else:
|
|
283
|
+
result_multi_dt[g] = 0.0
|
|
284
|
+
|
|
285
|
+
result_multi_dt.columns.name = 'group'
|
|
286
|
+
results.append((result_multi_dt, 'nodeGroup_flows_dt_g'))
|
|
287
|
+
|
|
288
|
+
# Aggregate to period level. Weight each (d, t) by par.timestep_weight
|
|
289
|
+
# (=1.0 with no/uniform timeset_weights → byte-identical) before the
|
|
290
|
+
# period sum. result_multi_dt is group inflow (already MWh/step), so no
|
|
291
|
+
# step_duration.
|
|
292
|
+
result_multi_d = annualize_dt_to_d(result_multi_dt, par.timestep_weight, par.complete_period_share_of_year)
|
|
293
|
+
result_multi_d.columns.name = 'group'
|
|
294
|
+
results.append((result_multi_d, 'nodeGroup_flows_d_g'))
|
|
295
|
+
|
|
296
|
+
return results
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def nodeGroup_flows(par, s, v, r, debug):
|
|
300
|
+
"""Group output flows for periods and time.
|
|
301
|
+
|
|
302
|
+
result_multi_dt combines sources that arrive in different units: slacks
|
|
303
|
+
and inflow are already MWh/step, but the v_flow-derived columns
|
|
304
|
+
(unit/connection flows, internal losses) are MW. We multiply the
|
|
305
|
+
latter by step_duration at assignment so every column is MWh/step at
|
|
306
|
+
the timestep level. The period-level output (nodeGroup_flows_d_gpe)
|
|
307
|
+
is then divided by complete_period_share_of_year so it reports
|
|
308
|
+
annualized MWh, matching node_d_ep and nodeGroup_flows_d_g.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
results = []
|
|
312
|
+
|
|
313
|
+
if s.nodeGroupDispatch.empty or s.dt_realize_dispatch.empty:
|
|
314
|
+
return results
|
|
315
|
+
|
|
316
|
+
step_dur = par.step_duration
|
|
317
|
+
|
|
318
|
+
def _to_energy(df: pd.DataFrame) -> pd.DataFrame:
|
|
319
|
+
"""Multiply a (period, time)-indexed MW frame by step_duration → MWh/step.
|
|
320
|
+
|
|
321
|
+
Guards against empty placeholder frames whose index lacks 'period'/'time'
|
|
322
|
+
level names (pandas' align-on-index would raise).
|
|
323
|
+
"""
|
|
324
|
+
if df.empty:
|
|
325
|
+
return df
|
|
326
|
+
return df.mul(step_dur, axis=0)
|
|
327
|
+
|
|
328
|
+
# Calculate timestep-level results first
|
|
329
|
+
result_multi_dt = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_tuples([], names=['group', 'type', 'item']))
|
|
330
|
+
|
|
331
|
+
# Assign simple mappings for all groups at once (before group loop)
|
|
332
|
+
# Slack upward
|
|
333
|
+
r.group_node_up_slack__dt.columns = pd.MultiIndex.from_arrays([
|
|
334
|
+
r.group_node_up_slack__dt.columns,
|
|
335
|
+
['slack'] * len(r.group_node_up_slack__dt.columns),
|
|
336
|
+
['upward'] * len(r.group_node_up_slack__dt.columns)
|
|
337
|
+
], names=['group', 'type', 'item'])
|
|
338
|
+
result_multi_dt[r.group_node_up_slack__dt.columns] = r.group_node_up_slack__dt
|
|
339
|
+
|
|
340
|
+
# Unit aggregates (aggregateUnits to group)
|
|
341
|
+
r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns = pd.MultiIndex.from_arrays([
|
|
342
|
+
r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns.get_level_values('group'),
|
|
343
|
+
['from_unitGroup'] * len(r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns),
|
|
344
|
+
r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns.get_level_values(1)
|
|
345
|
+
], names=['group', 'type', 'item'])
|
|
346
|
+
result_multi_dt[r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns] = _to_energy(r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt)
|
|
347
|
+
|
|
348
|
+
# Units not in aggregate (unit to group) - sum across nodes
|
|
349
|
+
r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns = pd.MultiIndex.from_arrays([
|
|
350
|
+
r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns.get_level_values('group'),
|
|
351
|
+
['from_unit'] * len(r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns),
|
|
352
|
+
r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns.get_level_values('process')
|
|
353
|
+
], names=['group', 'type', 'item'])
|
|
354
|
+
result_multi_dt[r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns] = _to_energy(r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt)
|
|
355
|
+
|
|
356
|
+
# Connection aggregates (from connections to group) - sum across nodes
|
|
357
|
+
r.nodeGroupDispatch__from_connection_aggregate__dt.columns = pd.MultiIndex.from_arrays([
|
|
358
|
+
r.nodeGroupDispatch__from_connection_aggregate__dt.columns.get_level_values('group'),
|
|
359
|
+
['from_connectionGroup'] * len(r.nodeGroupDispatch__from_connection_aggregate__dt.columns),
|
|
360
|
+
r.nodeGroupDispatch__from_connection_aggregate__dt.columns.get_level_values(1)
|
|
361
|
+
], names=['group', 'type', 'item'])
|
|
362
|
+
result_multi_dt[r.nodeGroupDispatch__from_connection_aggregate__dt.columns] = _to_energy(r.nodeGroupDispatch__from_connection_aggregate__dt)
|
|
363
|
+
|
|
364
|
+
# Connections not in aggregate (from connections)
|
|
365
|
+
r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns = pd.MultiIndex.from_arrays([
|
|
366
|
+
r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns.get_level_values('group'),
|
|
367
|
+
['from_connection'] * len(r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns),
|
|
368
|
+
r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns.get_level_values('process')
|
|
369
|
+
], names=['group', 'type', 'item'])
|
|
370
|
+
result_multi_dt[r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns] = _to_energy(r.nodeGroupDispatch__from_connection_not_in_aggregate__dt)
|
|
371
|
+
|
|
372
|
+
# Connections not in aggregate (to connections)
|
|
373
|
+
r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns = pd.MultiIndex.from_arrays([
|
|
374
|
+
r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns.get_level_values('group'),
|
|
375
|
+
['to_connection'] * len(r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns),
|
|
376
|
+
r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns.get_level_values('process')
|
|
377
|
+
], names=['group', 'type', 'item'])
|
|
378
|
+
result_multi_dt[r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns] = -_to_energy(r.nodeGroupDispatch__to_connection_not_in_aggregate__dt)
|
|
379
|
+
|
|
380
|
+
# Connection aggregates (to connections) - sum across nodes
|
|
381
|
+
r.nodeGroupDispatch__to_connection_aggregate__dt.columns = pd.MultiIndex.from_arrays([
|
|
382
|
+
r.nodeGroupDispatch__to_connection_aggregate__dt.columns.get_level_values('group'),
|
|
383
|
+
['to_connectionGroup'] * len(r.nodeGroupDispatch__to_connection_aggregate__dt.columns),
|
|
384
|
+
r.nodeGroupDispatch__to_connection_aggregate__dt.columns.get_level_values(1)
|
|
385
|
+
], names=['group', 'type', 'item'])
|
|
386
|
+
result_multi_dt[r.nodeGroupDispatch__to_connection_aggregate__dt.columns] = -_to_energy(r.nodeGroupDispatch__to_connection_aggregate__dt)
|
|
387
|
+
|
|
388
|
+
# Group to aggregate units (negative)
|
|
389
|
+
r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns = pd.MultiIndex.from_arrays([
|
|
390
|
+
r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns.get_level_values('group'),
|
|
391
|
+
['to_unitGroup'] * len(r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns),
|
|
392
|
+
r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns.get_level_values(1)
|
|
393
|
+
], names=['group', 'type', 'item'])
|
|
394
|
+
result_multi_dt[r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns] = -_to_energy(r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt)
|
|
395
|
+
|
|
396
|
+
# Node to unit not in aggregate (negative)
|
|
397
|
+
r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns = pd.MultiIndex.from_arrays([
|
|
398
|
+
r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns.get_level_values('group'),
|
|
399
|
+
['to_unit'] * len(r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns),
|
|
400
|
+
r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns.get_level_values('process')
|
|
401
|
+
], names=['group', 'type', 'item'])
|
|
402
|
+
result_multi_dt[r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns] = -_to_energy(r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt)
|
|
403
|
+
|
|
404
|
+
# Inflow
|
|
405
|
+
r.group_node_inflow_dt.columns = pd.MultiIndex.from_arrays([
|
|
406
|
+
r.group_node_inflow_dt.columns,
|
|
407
|
+
['inflow'] * len(r.group_node_inflow_dt.columns),
|
|
408
|
+
r.group_node_inflow_dt.columns
|
|
409
|
+
], names=['group', 'type', 'item'])
|
|
410
|
+
result_multi_dt[r.group_node_inflow_dt.columns] = r.group_node_inflow_dt
|
|
411
|
+
|
|
412
|
+
# Internal losses - connections (sum across processes, negate)
|
|
413
|
+
if not r.nodeGroupDispatch_Internal_connection_losses__dt.empty:
|
|
414
|
+
r.nodeGroupDispatch_Internal_connection_losses__dt = r.nodeGroupDispatch_Internal_connection_losses__dt.T.groupby('group').sum().T
|
|
415
|
+
r.nodeGroupDispatch_Internal_connection_losses__dt.columns = pd.MultiIndex.from_arrays([
|
|
416
|
+
r.nodeGroupDispatch_Internal_connection_losses__dt.columns,
|
|
417
|
+
['internal_losses'] * len(r.nodeGroupDispatch_Internal_connection_losses__dt.columns),
|
|
418
|
+
['connections'] * len(r.nodeGroupDispatch_Internal_connection_losses__dt.columns)
|
|
419
|
+
], names=['group', 'type', 'item'])
|
|
420
|
+
result_multi_dt[r.nodeGroupDispatch_Internal_connection_losses__dt.columns] = _to_energy(r.nodeGroupDispatch_Internal_connection_losses__dt)
|
|
421
|
+
|
|
422
|
+
# Internal losses - units (sum across processes, negate)
|
|
423
|
+
if not r.nodeGroupDispatch_Internal_unit_losses__dt.empty:
|
|
424
|
+
r.nodeGroupDispatch_Internal_unit_losses__dt = r.nodeGroupDispatch_Internal_unit_losses__dt.T.groupby('group').sum().T
|
|
425
|
+
r.nodeGroupDispatch_Internal_unit_losses__dt.columns = pd.MultiIndex.from_arrays([
|
|
426
|
+
r.nodeGroupDispatch_Internal_unit_losses__dt.columns,
|
|
427
|
+
['internal_losses'] * len(r.nodeGroupDispatch_Internal_unit_losses__dt.columns),
|
|
428
|
+
['units'] * len(r.nodeGroupDispatch_Internal_unit_losses__dt.columns)
|
|
429
|
+
], names=['group', 'type', 'item'])
|
|
430
|
+
result_multi_dt[r.nodeGroupDispatch_Internal_unit_losses__dt.columns] = _to_energy(r.nodeGroupDispatch_Internal_unit_losses__dt)
|
|
431
|
+
|
|
432
|
+
# Internal losses - storages (negate)
|
|
433
|
+
r.group_node_state_losses__dt.columns = pd.MultiIndex.from_arrays([
|
|
434
|
+
r.group_node_state_losses__dt.columns,
|
|
435
|
+
['internal_losses'] * len(r.group_node_state_losses__dt.columns),
|
|
436
|
+
['storages'] * len(r.group_node_state_losses__dt.columns)
|
|
437
|
+
], names=['group', 'type', 'item'])
|
|
438
|
+
result_multi_dt[r.group_node_state_losses__dt.columns] = _to_energy(r.group_node_state_losses__dt)
|
|
439
|
+
|
|
440
|
+
# Slack downward
|
|
441
|
+
r.group_node_down_slack__dt.columns = pd.MultiIndex.from_arrays([
|
|
442
|
+
r.group_node_down_slack__dt.columns,
|
|
443
|
+
['slack'] * len(r.group_node_down_slack__dt.columns),
|
|
444
|
+
['downward'] * len(r.group_node_down_slack__dt.columns)
|
|
445
|
+
], names=['group', 'type', 'item'])
|
|
446
|
+
result_multi_dt[r.group_node_down_slack__dt.columns] = r.group_node_down_slack__dt
|
|
447
|
+
|
|
448
|
+
# Tidy: drop all-zero (group, type, item) columns. The inflow,
|
|
449
|
+
# internal_losses storages and slack families are pre-allocated one
|
|
450
|
+
# column per group (filled with 0.0 even when a group has no inflow /
|
|
451
|
+
# no self-discharge node / no slack), unlike the join-built unit /
|
|
452
|
+
# connection families which only appear when non-empty. Without this
|
|
453
|
+
# an ``elec`` group that is a single loss-free node still emits an
|
|
454
|
+
# ``internal_losses | storages`` column of zeros, which reads as a
|
|
455
|
+
# spurious participant in the plot legend. ``result_multi_d`` is
|
|
456
|
+
# derived from ``result_multi_dt`` below, so both stay consistent.
|
|
457
|
+
if result_multi_dt.shape[1] > 0:
|
|
458
|
+
result_multi_dt = result_multi_dt.loc[:, (result_multi_dt != 0).any(axis=0)]
|
|
459
|
+
|
|
460
|
+
result_multi_dt.columns.names = ['group', 'type', 'item']
|
|
461
|
+
result_multi_dt = result_multi_dt.sort_index(axis=1, level='group', sort_remaining=False)
|
|
462
|
+
|
|
463
|
+
# Return timestep results
|
|
464
|
+
results.append((result_multi_dt, 'nodeGroup_flows_dt_gpe'))
|
|
465
|
+
|
|
466
|
+
# Aggregate to period level and annualize to match node_d_ep convention.
|
|
467
|
+
# result_multi_dt is already energy (MWh/step); weight each (d, t) by
|
|
468
|
+
# par.timestep_weight (=1.0 with no/uniform timeset_weights →
|
|
469
|
+
# byte-identical) before the period sum.
|
|
470
|
+
result_multi_d = annualize_dt_to_d(result_multi_dt, par.timestep_weight, par.complete_period_share_of_year)
|
|
471
|
+
|
|
472
|
+
# Return period results
|
|
473
|
+
results.append((result_multi_d, 'nodeGroup_flows_d_gpe'))
|
|
474
|
+
|
|
475
|
+
return results
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
from flextool.process_outputs._annualize import annualize_dt_to_d
|
|
4
|
+
|
|
5
|
+
# Storage node is surfaced in the node balance/price outputs only if it is
|
|
6
|
+
# wired to more than this many distinct processes. A battery (one inverter,
|
|
7
|
+
# or separate charge + discharge units ⇒ ≤2 processes) stays hidden to avoid
|
|
8
|
+
# cluttering the outputs; a multi-use hub (e.g. a hydrogen node feeding an
|
|
9
|
+
# electrolyser, a turbine and several conversion routes ⇒ many processes) is
|
|
10
|
+
# surfaced automatically.
|
|
11
|
+
MULTI_USE_STORAGE_PROCESS_THRESHOLD = 2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _multi_use_storage_nodes(s):
|
|
15
|
+
"""Storage nodes wired to more than the threshold of distinct processes.
|
|
16
|
+
|
|
17
|
+
Counts distinct process *names* across the node's source- and sink-side
|
|
18
|
+
arcs (``s.process_source`` / ``s.process_sink``), so a bidirectional unit
|
|
19
|
+
appearing on both sides counts once. Arc-level only — never touches the
|
|
20
|
+
``(d, t)`` timeline. Returns a ``node``-named Index that is a subset of
|
|
21
|
+
``s.node_state``; the complement (boring storage) is what gets hidden.
|
|
22
|
+
"""
|
|
23
|
+
if len(s.node_state) == 0:
|
|
24
|
+
return s.node_state
|
|
25
|
+
src = s.process_source.to_frame(index=False).rename(columns={"source": "node"})
|
|
26
|
+
snk = s.process_sink.to_frame(index=False).rename(columns={"sink": "node"})
|
|
27
|
+
arcs = pd.concat([src, snk], ignore_index=True)
|
|
28
|
+
arcs = arcs[arcs["node"].isin(s.node_state)]
|
|
29
|
+
if arcs.empty:
|
|
30
|
+
return pd.Index([], name="node", dtype=s.node_state.dtype)
|
|
31
|
+
counts = arcs.groupby("node")["process"].nunique()
|
|
32
|
+
return pd.Index(
|
|
33
|
+
counts.index[counts > MULTI_USE_STORAGE_PROCESS_THRESHOLD], name="node",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def node_summary(par, s, v, r, debug):
|
|
38
|
+
"""Node balance summaries for periods and timesteps"""
|
|
39
|
+
results = []
|
|
40
|
+
|
|
41
|
+
categories = ['From units', 'From connections', 'Loss of load', 'To units', 'To connections', 'Self discharge', 'Excess load', 'Inflow']
|
|
42
|
+
|
|
43
|
+
balanced_nodes = s.node_balance.union(s.node_balance_period)
|
|
44
|
+
if debug:
|
|
45
|
+
nodes = s.node
|
|
46
|
+
else:
|
|
47
|
+
# Hide boring storage nodes (batteries); keep multi-use storage hubs.
|
|
48
|
+
hidden_storage = s.node_state.difference(_multi_use_storage_nodes(s))
|
|
49
|
+
nodes = balanced_nodes.difference(hidden_storage)
|
|
50
|
+
nodes_sink = s.node.copy().intersection(nodes)
|
|
51
|
+
nodes_sink.name = 'sink'
|
|
52
|
+
nodes_source = s.node.copy().intersection(nodes)
|
|
53
|
+
nodes_source.name = 'source'
|
|
54
|
+
|
|
55
|
+
# 1. Timestep-level node summary
|
|
56
|
+
node_dt = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_product([nodes, categories], names=['node', 'category']), dtype=float)
|
|
57
|
+
|
|
58
|
+
# From units
|
|
59
|
+
from_units = r.flow_dt[s.process_unit.join(r.flow_dt.columns).join(nodes_sink, how='inner')].T.groupby('sink').sum().T
|
|
60
|
+
from_units_cols = node_dt.columns[
|
|
61
|
+
node_dt.columns.get_level_values('node').isin(from_units.columns)
|
|
62
|
+
& node_dt.columns.get_level_values('category').isin(['From units'])]
|
|
63
|
+
node_dt[from_units_cols] = from_units[from_units_cols.get_level_values('node')]
|
|
64
|
+
|
|
65
|
+
# From connections
|
|
66
|
+
from_connections = r.flow_dt[s.process_connection.join(r.flow_dt.columns).join(nodes_sink, how='inner')].T.groupby('sink').sum().T
|
|
67
|
+
from_connections_cols = node_dt.columns[
|
|
68
|
+
node_dt.columns.get_level_values('node').isin(from_connections.columns)
|
|
69
|
+
& node_dt.columns.get_level_values('category').isin(['From connections'])]
|
|
70
|
+
node_dt[from_connections_cols] = from_connections[from_connections_cols.get_level_values('node')]
|
|
71
|
+
|
|
72
|
+
# Upward slack
|
|
73
|
+
upward_slack_data = v.q_state_up.loc[:, v.q_state_up.columns.get_level_values('node').isin(balanced_nodes.intersection(nodes))].clip(lower=0)
|
|
74
|
+
# node_capacity_for_scaling is complete over nodeBalance ∪
|
|
75
|
+
# nodeBalancePeriod (⊇ q_state nodes), so direct column indexing is safe.
|
|
76
|
+
upward_slack_data = upward_slack_data.mul(par.node_capacity_for_scaling[upward_slack_data.columns], axis=1)
|
|
77
|
+
upward_slack_cols = node_dt.columns[
|
|
78
|
+
node_dt.columns.get_level_values('node').isin(upward_slack_data.columns.get_level_values('node'))
|
|
79
|
+
& node_dt.columns.get_level_values('category').isin(['Loss of load'])]
|
|
80
|
+
node_dt[upward_slack_cols] = upward_slack_data[upward_slack_cols.get_level_values('node')]
|
|
81
|
+
|
|
82
|
+
# To units (negative)
|
|
83
|
+
to_units = -r.flow_dt[s.process_unit.join(r.flow_dt.columns).join(nodes_source, how='inner')].T.groupby('source').sum().T
|
|
84
|
+
to_units_cols = node_dt.columns[
|
|
85
|
+
node_dt.columns.get_level_values('node').isin(to_units.columns)
|
|
86
|
+
& node_dt.columns.get_level_values('category').isin(['To units'])]
|
|
87
|
+
node_dt[to_units_cols] = to_units[to_units_cols.get_level_values('node')]
|
|
88
|
+
|
|
89
|
+
# To connections (negative)
|
|
90
|
+
to_connections = -r.flow_dt[s.process_connection.join(r.flow_dt.columns).join(nodes_source, how='inner')].T.groupby('source').sum().T
|
|
91
|
+
to_connections_cols = node_dt.columns[
|
|
92
|
+
node_dt.columns.get_level_values('node').isin(to_connections.columns)
|
|
93
|
+
& node_dt.columns.get_level_values('category').isin(['To connections'])]
|
|
94
|
+
node_dt[to_connections_cols] = to_connections[to_connections_cols.get_level_values('node')]
|
|
95
|
+
|
|
96
|
+
# Self discharge (negative)
|
|
97
|
+
self_discharge = -r.self_discharge_loss_dt[r.self_discharge_loss_dt.columns.intersection(s.node_self_discharge.intersection(nodes))]
|
|
98
|
+
self_discharge_cols = node_dt.columns[
|
|
99
|
+
node_dt.columns.get_level_values('node').isin(self_discharge.columns)
|
|
100
|
+
& node_dt.columns.get_level_values('category').isin(['Self discharge'])]
|
|
101
|
+
node_dt[self_discharge_cols] = self_discharge[self_discharge_cols.get_level_values('node')]
|
|
102
|
+
|
|
103
|
+
# Downward slack (negative)
|
|
104
|
+
downward_slack_data = (-v.q_state_down.loc[:, v.q_state_down.columns.get_level_values('node').isin(balanced_nodes.intersection(nodes))]).clip(upper=0)
|
|
105
|
+
downward_slack_data = downward_slack_data.mul(par.node_capacity_for_scaling[downward_slack_data.columns], axis=1)
|
|
106
|
+
downward_slack_cols = node_dt.columns[
|
|
107
|
+
node_dt.columns.get_level_values('node').isin(downward_slack_data.columns.get_level_values('node'))
|
|
108
|
+
& node_dt.columns.get_level_values('category').isin(['Excess load'])]
|
|
109
|
+
node_dt[downward_slack_cols] = downward_slack_data[downward_slack_cols.get_level_values('node')]
|
|
110
|
+
|
|
111
|
+
# Inflow
|
|
112
|
+
inflow_cols = node_dt.columns[
|
|
113
|
+
node_dt.columns.get_level_values('node').isin(par.node_inflow.columns.intersection(nodes))
|
|
114
|
+
& node_dt.columns.get_level_values('category').isin(['Inflow'])]
|
|
115
|
+
node_dt[inflow_cols] = par.node_inflow[inflow_cols.get_level_values('node')]
|
|
116
|
+
|
|
117
|
+
# Fill any remaining NaN values with 0
|
|
118
|
+
node_dt = node_dt.fillna(0.0)
|
|
119
|
+
|
|
120
|
+
results.append((node_dt, 'node_dt_ep'))
|
|
121
|
+
|
|
122
|
+
# 2. Period-level node summary. Most categories (flows, slacks, self
|
|
123
|
+
# discharge) are MW — multiply by step_duration to get MWh per step.
|
|
124
|
+
# Inflow is already MWh/step (par.node_inflow is energy per step, see
|
|
125
|
+
# mod comment at flextool.mod:2319), so compute it separately.
|
|
126
|
+
energy_categories = [c for c in categories if c != 'Inflow']
|
|
127
|
+
energy_cols = node_dt.columns[node_dt.columns.get_level_values('category').isin(energy_categories)]
|
|
128
|
+
inflow_cols = node_dt.columns[node_dt.columns.get_level_values('category') == 'Inflow']
|
|
129
|
+
|
|
130
|
+
# Weight each (d, t) by par.timestep_weight (=1.0 with no/uniform
|
|
131
|
+
# timeset_weights → byte-identical) before the period sum, so reported
|
|
132
|
+
# annual energy matches the cost-weighted objective.
|
|
133
|
+
node_d_energy = annualize_dt_to_d(
|
|
134
|
+
node_dt[energy_cols], par.timestep_weight,
|
|
135
|
+
par.complete_period_share_of_year, par.step_duration, div_level=1)
|
|
136
|
+
node_d_inflow = annualize_dt_to_d(
|
|
137
|
+
node_dt[inflow_cols], par.timestep_weight,
|
|
138
|
+
par.complete_period_share_of_year, div_level=1)
|
|
139
|
+
node_d = pd.concat([node_d_energy, node_d_inflow], axis=1).reindex(columns=node_dt.columns)
|
|
140
|
+
|
|
141
|
+
results.append((node_d, 'node_d_ep'))
|
|
142
|
+
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def node_additional_results(par, s, v, r, debug):
|
|
147
|
+
"""Additional node results: prices, state, and slacks"""
|
|
148
|
+
results = []
|
|
149
|
+
|
|
150
|
+
# 1. Nodal prices — the per-(d,t) nodal-balance dual. Per-step balance
|
|
151
|
+
# nodes carry ``nodeBalance_eq``; coarse variable-resolution nodes
|
|
152
|
+
# (``new_stepduration`` → ``nodeStateBlock``) carry ``nodeBalanceBlock_eq``
|
|
153
|
+
# broadcast to per-(d,t) — both are merged into ``v.dual_node_balance``
|
|
154
|
+
# by ``write_v_dual_node_balance``. Defensive intersection with the
|
|
155
|
+
# available columns (order-preserving): every requested node normally
|
|
156
|
+
# has a dual, but if a node genuinely lacks one (e.g. a future balance
|
|
157
|
+
# mode without a dual writer) this omits it rather than raising
|
|
158
|
+
# ``KeyError: None of [...] are in the columns``.
|
|
159
|
+
# Mirror the node-balance summary: hide boring storage nodes (batteries),
|
|
160
|
+
# keep multi-use storage hubs. For a battery the balance dual is largely
|
|
161
|
+
# redundant with the connected electricity node's price (and most prone to
|
|
162
|
+
# degeneracy at the storage margin); for a multi-use hub (e.g. a hydrogen
|
|
163
|
+
# node) it is the genuine, otherwise-unavailable marginal value of that
|
|
164
|
+
# commodity — the case worth reporting.
|
|
165
|
+
hidden_storage = s.node_state.difference(_multi_use_storage_nodes(s))
|
|
166
|
+
price_nodes = s.node_balance.difference(hidden_storage).intersection(
|
|
167
|
+
v.dual_node_balance.columns)
|
|
168
|
+
results.append((v.dual_node_balance[price_nodes], 'node_prices_dt_e'))
|
|
169
|
+
|
|
170
|
+
# 2. Node state
|
|
171
|
+
node_state = v.state.mul(par.entity_unitsize[s.node_state], level="node")
|
|
172
|
+
results.append((node_state, 'node_state_dt_e'))
|
|
173
|
+
|
|
174
|
+
# 3. Node upward slack (MW at timestep; multiply by step_duration for MWh period)
|
|
175
|
+
upward_slack = v.q_state_up.mul(par.node_capacity_for_scaling[s.node_balance.union(s.node_balance_period)], level=0).clip(lower=0)
|
|
176
|
+
results.append((upward_slack, 'node_slack_up_dt_e'))
|
|
177
|
+
upward_slack_d = annualize_dt_to_d(
|
|
178
|
+
upward_slack, par.timestep_weight, par.complete_period_share_of_year,
|
|
179
|
+
par.step_duration, div_level=1)
|
|
180
|
+
results.append((upward_slack_d, 'node_slack_up_d_e'))
|
|
181
|
+
|
|
182
|
+
# 4. Node downward slack (MW at timestep; multiply by step_duration for MWh period)
|
|
183
|
+
downward_slack = v.q_state_down.mul(par.node_capacity_for_scaling[s.node_balance.union(s.node_balance_period)], level=0).clip(lower=0)
|
|
184
|
+
results.append((downward_slack, 'node_slack_down_dt_e'))
|
|
185
|
+
downward_slack_d = annualize_dt_to_d(
|
|
186
|
+
downward_slack, par.timestep_weight, par.complete_period_share_of_year,
|
|
187
|
+
par.step_duration, div_level=1)
|
|
188
|
+
results.append((downward_slack_d, 'node_slack_down_d_e'))
|
|
189
|
+
|
|
190
|
+
return results
|