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,563 @@
|
|
|
1
|
+
"""Net-load force-include scoring for representative-period selection.
|
|
2
|
+
|
|
3
|
+
Pure numpy scoring functions that identify adequacy-critical *base periods*
|
|
4
|
+
to force into the representative set, alongside the hull picks. No database
|
|
5
|
+
access — every input is passed in by the caller (``preprocess.py``).
|
|
6
|
+
|
|
7
|
+
Background (see ``specs/repperiod_forceinclude_design.md`` §2 and
|
|
8
|
+
``specs/rp_force_include_build_decisions.md``). The greedy convex-hull
|
|
9
|
+
clustering optimises a whole-year L2 approximation of *shape* and is blind to
|
|
10
|
+
adequacy: a sustained low-VRE / high-demand trough can have an unremarkable
|
|
11
|
+
within-period shape and never get selected, so the investment solve never
|
|
12
|
+
sees the stress week as a hard balance constraint. Force-include injects the
|
|
13
|
+
worst base period(s) under an explicit system-coincident net-load signal.
|
|
14
|
+
|
|
15
|
+
Scope (per the build-decisions file, which overrides the design where they
|
|
16
|
+
disagree):
|
|
17
|
+
|
|
18
|
+
* **Single ``vg_weight`` knob** — the convex blend between the VG-shortfall
|
|
19
|
+
term and the inflow-demand term. Inflow gets the remainder
|
|
20
|
+
``1 - vg_weight``. There is no per-category weight and no storage/non-storage
|
|
21
|
+
node split.
|
|
22
|
+
* **System scope only** — no ``region_of()`` / name parsing / per-region code.
|
|
23
|
+
The signal is a single system-coincident aggregate.
|
|
24
|
+
|
|
25
|
+
FlexTool sign convention (the gotcha): **demand is NEGATIVE inflow**, supply is
|
|
26
|
+
positive inflow. A demand node's ``inflow`` value is negative; its demand
|
|
27
|
+
magnitude is ``|value|``. So ``-inflow_h`` summed over nodes is the system net
|
|
28
|
+
demand at hour ``h``, and a scalar (constant) demand node contributes
|
|
29
|
+
``+|value|`` as a fixed demand level.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
# Series whose scale (mean absolute value) is below this are treated as
|
|
37
|
+
# carrying no information and normalise to all-zeros, rather than dividing by
|
|
38
|
+
# a near-zero denominator.
|
|
39
|
+
_SCALE_EPS = 1e-12
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _series_matrix(
|
|
43
|
+
series: dict[str, list[tuple[str, float]]],
|
|
44
|
+
timestep_keys: list[str],
|
|
45
|
+
) -> np.ndarray | None:
|
|
46
|
+
"""Stack fully-covering time series into a ``(n_series, n_hours)`` matrix.
|
|
47
|
+
|
|
48
|
+
Each entry of ``series`` maps a name to ``[(timestep_key, value), ...]``.
|
|
49
|
+
A series is included only if it has a value at *every* key in
|
|
50
|
+
``timestep_keys`` (matching ``preprocess._build_clustering_matrix``, which
|
|
51
|
+
skips partial-coverage series). Returns ``None`` when no series qualifies.
|
|
52
|
+
"""
|
|
53
|
+
rows: list[list[float]] = []
|
|
54
|
+
for data in series.values():
|
|
55
|
+
lookup = dict(data)
|
|
56
|
+
if all(k in lookup for k in timestep_keys):
|
|
57
|
+
rows.append([float(lookup[k]) for k in timestep_keys])
|
|
58
|
+
if not rows:
|
|
59
|
+
return None
|
|
60
|
+
return np.asarray(rows, dtype=np.float64)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _normalize(term: np.ndarray) -> np.ndarray:
|
|
64
|
+
"""Scale a signal by its own mean-absolute value.
|
|
65
|
+
|
|
66
|
+
Dividing each of the two net-load terms by its mean-absolute magnitude puts
|
|
67
|
+
them on a comparable, dimensionless scale before the convex blend, so
|
|
68
|
+
neither dominates purely by physical units (a VG shortfall lives in [0, 1],
|
|
69
|
+
a demand level can be in the hundreds).
|
|
70
|
+
|
|
71
|
+
Mean-absolute (rather than max) is chosen because it is robust to a single
|
|
72
|
+
outlier hour — one freak spike would otherwise shrink the rest of the
|
|
73
|
+
signal toward zero and flatten period-to-period differences.
|
|
74
|
+
|
|
75
|
+
Key property this preserves: a **constant** term (e.g. the inflow term for
|
|
76
|
+
a scalar-only system) normalises to a constant ``1.0`` everywhere, so it
|
|
77
|
+
shifts ``netload`` uniformly and cannot change the ``argmax`` over periods.
|
|
78
|
+
That is what makes the scalar-inflow result invariant to ``vg_weight``.
|
|
79
|
+
A near-zero-scale term (no information) normalises to all zeros.
|
|
80
|
+
"""
|
|
81
|
+
scale = float(np.mean(np.abs(term)))
|
|
82
|
+
if scale < _SCALE_EPS:
|
|
83
|
+
return np.zeros_like(term)
|
|
84
|
+
return term / scale
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _weighted_vg_term(
|
|
88
|
+
profiles: dict[str, list[tuple[str, float]]],
|
|
89
|
+
timestep_keys: list[str],
|
|
90
|
+
region_profiles: dict[str, list[str]],
|
|
91
|
+
region_demand: dict[str, float],
|
|
92
|
+
n_hours: int,
|
|
93
|
+
) -> np.ndarray | None:
|
|
94
|
+
"""Node-group demand-weighted VG-shortfall term (design §2.0).
|
|
95
|
+
|
|
96
|
+
Implements ``vg_term_h = Σ_r D_r · mean_{p ∈ region r}(1 - avail_{p,h})`` —
|
|
97
|
+
the mean shortfall *within* each region, then a ``D_r``-weighted *sum*
|
|
98
|
+
across regions (NOT a flat weighted mean over all profiles). This up-weights
|
|
99
|
+
the shortfall of high-demand regions, which is what makes a coincident
|
|
100
|
+
trough in the big importers dominate the argmax.
|
|
101
|
+
|
|
102
|
+
Only profiles that (a) map to one of the named regions and (b) fully cover
|
|
103
|
+
the horizon contribute. Returns ``None`` (caller falls back to the
|
|
104
|
+
unweighted term with a warning) when no profile maps to a positive-``D_r``
|
|
105
|
+
region — i.e. the weighting carries no information.
|
|
106
|
+
"""
|
|
107
|
+
vg_term = np.zeros(n_hours, dtype=np.float64)
|
|
108
|
+
used_profiles = 0
|
|
109
|
+
total_mapped = 0
|
|
110
|
+
for region, profile_names in region_profiles.items():
|
|
111
|
+
d_r = float(region_demand.get(region, 0.0))
|
|
112
|
+
# Stack the fully-covering profiles of this region.
|
|
113
|
+
region_series = {
|
|
114
|
+
name: profiles[name] for name in profile_names if name in profiles
|
|
115
|
+
}
|
|
116
|
+
total_mapped += len(region_series)
|
|
117
|
+
avail = _series_matrix(region_series, timestep_keys)
|
|
118
|
+
if avail is None:
|
|
119
|
+
continue
|
|
120
|
+
if d_r <= 0.0:
|
|
121
|
+
# A named region that owns profiles but has no scalar demand: warn,
|
|
122
|
+
# it contributes nothing to the weighted sum.
|
|
123
|
+
print(
|
|
124
|
+
f" Force-include: region '{region}' owns "
|
|
125
|
+
f"{avail.shape[0]} profile(s) but D_r == 0 — it does not "
|
|
126
|
+
f"weight the net-load signal."
|
|
127
|
+
)
|
|
128
|
+
continue
|
|
129
|
+
vg_term += d_r * (1.0 - avail.mean(axis=0))
|
|
130
|
+
used_profiles += avail.shape[0]
|
|
131
|
+
|
|
132
|
+
if used_profiles == 0:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
n_profiles = sum(1 for _ in profiles)
|
|
136
|
+
unmapped = n_profiles - total_mapped
|
|
137
|
+
if unmapped > 0:
|
|
138
|
+
print(
|
|
139
|
+
f" Force-include: {unmapped} profile(s) not mapped to any named "
|
|
140
|
+
f"region group — excluded from the demand-weighted VG term."
|
|
141
|
+
)
|
|
142
|
+
return vg_term
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_netload_hourly(
|
|
146
|
+
profiles: dict[str, list[tuple[str, float]]],
|
|
147
|
+
inflows: dict[str, list[tuple[str, float]]],
|
|
148
|
+
demand_scalars: dict[str, float],
|
|
149
|
+
timestep_keys: list[str],
|
|
150
|
+
*,
|
|
151
|
+
vg_weight: float,
|
|
152
|
+
region_profiles: dict[str, list[str]] | None = None,
|
|
153
|
+
region_demand: dict[str, float] | None = None,
|
|
154
|
+
) -> np.ndarray:
|
|
155
|
+
"""Build the system-coincident net-load signal, one value per timestep.
|
|
156
|
+
|
|
157
|
+
Implements the single-knob system aggregate of the net-load formula in
|
|
158
|
+
``rp_force_include_build_decisions.md``. There are two weighting modes for
|
|
159
|
+
the VG-shortfall term, selected by whether ``region_profiles`` /
|
|
160
|
+
``region_demand`` are supplied:
|
|
161
|
+
|
|
162
|
+
* **Unweighted (default)** — the mean over *all* profile series of
|
|
163
|
+
``1 - availability_h`` (availability is 0-1). High when VRE is low
|
|
164
|
+
system-wide. This is the byte-parity path.
|
|
165
|
+
* **Node-group demand-weighted** — when both ``region_profiles`` and
|
|
166
|
+
``region_demand`` are given, ``vg_term_h = Σ_r D_r · mean_{p ∈ r}(1 -
|
|
167
|
+
avail_{p,h})`` (see :func:`_weighted_vg_term`): the within-region mean
|
|
168
|
+
shortfall, ``D_r``-weighted and *summed* into one coincident signal. This
|
|
169
|
+
up-weights high-demand regions so a coincident trough in the big
|
|
170
|
+
importers dominates. Falls back to the unweighted term (with a warning)
|
|
171
|
+
when no profile maps to a positive-``D_r`` region.
|
|
172
|
+
|
|
173
|
+
The inflow-demand term is unchanged in both modes: system net demand at
|
|
174
|
+
hour ``h``, ``Σ|demand_scalars| - Σ_nodes inflow_h`` over the time-varying
|
|
175
|
+
inflow nodes. The scalar sum is a constant demand *level* (demand is
|
|
176
|
+
negative inflow, so each scalar contributes ``+|value|``); the time-varying
|
|
177
|
+
part enters with a minus sign so that a more-negative (larger-demand)
|
|
178
|
+
inflow raises the term and a positive (supply) inflow lowers it. It is
|
|
179
|
+
already in demand/energy units, so only the dimensionless VG availability
|
|
180
|
+
term needs ``D_r`` scaling.
|
|
181
|
+
|
|
182
|
+
Each term is normalised by its own mean-absolute value (see
|
|
183
|
+
:func:`_normalize`) so they are comparably scaled, then blended:
|
|
184
|
+
|
|
185
|
+
``netload_h = vg_weight * vg_norm_h + (1 - vg_weight) * inflow_norm_h``
|
|
186
|
+
|
|
187
|
+
Invariance guarantee: for a system whose inflow is purely scalar (the
|
|
188
|
+
H2_trade case), the inflow term is constant, so ``inflow_norm_h`` is a
|
|
189
|
+
constant ``1.0``. Adding a constant to every hour cannot move the ``argmax``
|
|
190
|
+
over periods, and scaling the VG contribution by a positive ``vg_weight``
|
|
191
|
+
cannot either — so the forced period is invariant to ``vg_weight`` for any
|
|
192
|
+
``vg_weight > 0``. (A unit test pins this.)
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
profiles: VRE availability series (name -> [(key, value), ...]), 0-1.
|
|
196
|
+
inflows: Time-varying node inflow series (name -> [(key, value), ...]).
|
|
197
|
+
demand_scalars: Constant node inflows (name -> scalar value), the
|
|
198
|
+
dropped-by-clustering scalars collected as demand levels.
|
|
199
|
+
timestep_keys: Ordered timestep keys defining the horizon.
|
|
200
|
+
vg_weight: Convex blend weight in [0, 1] on the VG term; the inflow
|
|
201
|
+
term gets ``1 - vg_weight``.
|
|
202
|
+
region_profiles: Optional region -> profile names mapping. When given
|
|
203
|
+
together with ``region_demand``, enables demand-weighting.
|
|
204
|
+
region_demand: Optional region -> demand level ``D_r`` mapping.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
1-D array of length ``len(timestep_keys)``.
|
|
208
|
+
"""
|
|
209
|
+
n_hours = len(timestep_keys)
|
|
210
|
+
|
|
211
|
+
# VG-shortfall term: unweighted mean, or node-group demand-weighted sum.
|
|
212
|
+
vg_term: np.ndarray | None = None
|
|
213
|
+
if region_profiles is not None and region_demand is not None:
|
|
214
|
+
vg_term = _weighted_vg_term(
|
|
215
|
+
profiles, timestep_keys, region_profiles, region_demand, n_hours
|
|
216
|
+
)
|
|
217
|
+
if vg_term is None:
|
|
218
|
+
print(
|
|
219
|
+
" Force-include: no profile mapped to a positive-demand "
|
|
220
|
+
"region group — falling back to unweighted VG term."
|
|
221
|
+
)
|
|
222
|
+
if vg_term is None:
|
|
223
|
+
avail = _series_matrix(profiles, timestep_keys)
|
|
224
|
+
if avail is not None:
|
|
225
|
+
vg_term = 1.0 - avail.mean(axis=0)
|
|
226
|
+
else:
|
|
227
|
+
vg_term = np.zeros(n_hours, dtype=np.float64)
|
|
228
|
+
|
|
229
|
+
# Inflow-demand term: constant scalar demand level minus time-varying inflow.
|
|
230
|
+
scalar_demand = sum(abs(float(v)) for v in demand_scalars.values())
|
|
231
|
+
tv_inflow = _series_matrix(inflows, timestep_keys)
|
|
232
|
+
if tv_inflow is not None:
|
|
233
|
+
inflow_term = scalar_demand - tv_inflow.sum(axis=0)
|
|
234
|
+
else:
|
|
235
|
+
inflow_term = np.full(n_hours, scalar_demand, dtype=np.float64)
|
|
236
|
+
|
|
237
|
+
vg_norm = _normalize(vg_term)
|
|
238
|
+
inflow_norm = _normalize(inflow_term)
|
|
239
|
+
return vg_weight * vg_norm + (1.0 - vg_weight) * inflow_norm
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def score_peak(
|
|
243
|
+
netload: np.ndarray,
|
|
244
|
+
period_length: int,
|
|
245
|
+
n_base: int,
|
|
246
|
+
) -> np.ndarray:
|
|
247
|
+
"""Per-period peak net load (§2.1, Flag A).
|
|
248
|
+
|
|
249
|
+
``score_peak[d] = max_{h in period d} netload_h`` — the single worst hour
|
|
250
|
+
in each aligned base period. Capacity-adequacy driver; noisy for
|
|
251
|
+
energy-constrained systems.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
netload: Hourly net-load signal.
|
|
255
|
+
period_length: Timesteps per aligned base period.
|
|
256
|
+
n_base: Number of aligned base periods (``len(netload) // period_length``
|
|
257
|
+
or fewer; the horizon is truncated to ``n_base * period_length``).
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
1-D array of length ``n_base``.
|
|
261
|
+
"""
|
|
262
|
+
n_used = n_base * period_length
|
|
263
|
+
grid = np.asarray(netload[:n_used], dtype=np.float64).reshape(n_base, period_length)
|
|
264
|
+
return grid.max(axis=1)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def score_net(
|
|
268
|
+
netload: np.ndarray,
|
|
269
|
+
period_length: int,
|
|
270
|
+
n_base: int,
|
|
271
|
+
window: int | None = None,
|
|
272
|
+
) -> np.ndarray:
|
|
273
|
+
"""Per-period worst sustained net load (§2.2, Flag B — the energy fix).
|
|
274
|
+
|
|
275
|
+
``score_net[d] = max_{h0} mean_{h in [h0, h0+window)} netload_h`` within
|
|
276
|
+
period ``d`` — the worst sustained sub-window mean. Rewards *sustained*
|
|
277
|
+
troughs rather than a single spiky hour.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
netload: Hourly net-load signal.
|
|
281
|
+
period_length: Timesteps per aligned base period.
|
|
282
|
+
n_base: Number of aligned base periods.
|
|
283
|
+
window: Sub-window length in timesteps. ``None`` (the default) means the
|
|
284
|
+
whole-period mean (``window == period_length``). Clamped to
|
|
285
|
+
``[1, period_length]``.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
1-D array of length ``n_base``.
|
|
289
|
+
"""
|
|
290
|
+
if window is None:
|
|
291
|
+
window = period_length
|
|
292
|
+
window = max(1, min(int(window), period_length))
|
|
293
|
+
|
|
294
|
+
n_used = n_base * period_length
|
|
295
|
+
grid = np.asarray(netload[:n_used], dtype=np.float64).reshape(n_base, period_length)
|
|
296
|
+
|
|
297
|
+
if window == period_length:
|
|
298
|
+
return grid.mean(axis=1)
|
|
299
|
+
|
|
300
|
+
# Sliding-window means over each period, take the worst (max) start position.
|
|
301
|
+
windows = np.lib.stride_tricks.sliding_window_view(grid, window, axis=1)
|
|
302
|
+
return windows.mean(axis=2).max(axis=1)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _greedy_region_cover(
|
|
306
|
+
region_candidates: dict[str, list[int]],
|
|
307
|
+
region_scores: dict[str, np.ndarray],
|
|
308
|
+
budget: int,
|
|
309
|
+
) -> list[int]:
|
|
310
|
+
"""Greedy budgeted max-coverage of regions by forced base periods.
|
|
311
|
+
|
|
312
|
+
A generic weighted set-cover: each region contributes a small candidate set
|
|
313
|
+
of base periods (its worst-lull period(s)); a base period *covers* every
|
|
314
|
+
region whose candidate set contains it. Repeatedly pick the not-yet-selected
|
|
315
|
+
period covering the most still-uncovered regions, adding it to the forced
|
|
316
|
+
set, until the ``budget`` cap is spent or every region is covered.
|
|
317
|
+
|
|
318
|
+
A single period that is the coincident worst-lull of several regions covers
|
|
319
|
+
all of them at once — that is the dedup the design calls for (it is never
|
|
320
|
+
forced twice, and covering many regions makes it win the greedy pick).
|
|
321
|
+
|
|
322
|
+
Determinism: ties on coverage are broken by the greater summed score over
|
|
323
|
+
the newly-covered regions (prefer the deeper lull), then by the lower period
|
|
324
|
+
index. No hidden dependence on dict iteration order.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
region_candidates: region -> list of covering base-period indices.
|
|
328
|
+
region_scores: region -> per-period score array (for tie-breaking).
|
|
329
|
+
budget: maximum number of forced periods (cap on the returned set).
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Sorted list of forced base-period indices (length <= ``budget``).
|
|
333
|
+
"""
|
|
334
|
+
uncovered: set[str] = set(region_candidates)
|
|
335
|
+
period_regions: dict[int, set[str]] = {}
|
|
336
|
+
for region, cands in region_candidates.items():
|
|
337
|
+
for p in cands:
|
|
338
|
+
period_regions.setdefault(p, set()).add(region)
|
|
339
|
+
|
|
340
|
+
selected: list[int] = []
|
|
341
|
+
selected_set: set[int] = set()
|
|
342
|
+
while uncovered and len(selected) < budget:
|
|
343
|
+
best_p: int | None = None
|
|
344
|
+
best_key: tuple[int, float, int] | None = None
|
|
345
|
+
for p, regs in period_regions.items():
|
|
346
|
+
if p in selected_set:
|
|
347
|
+
continue
|
|
348
|
+
newly = regs & uncovered
|
|
349
|
+
if not newly:
|
|
350
|
+
continue
|
|
351
|
+
score_sum = sum(float(region_scores[r][p]) for r in newly)
|
|
352
|
+
# maximize coverage, then summed score, then prefer lower index.
|
|
353
|
+
key = (len(newly), score_sum, -p)
|
|
354
|
+
if best_key is None or key > best_key:
|
|
355
|
+
best_key = key
|
|
356
|
+
best_p = p
|
|
357
|
+
if best_p is None:
|
|
358
|
+
break
|
|
359
|
+
selected.append(best_p)
|
|
360
|
+
selected_set.add(best_p)
|
|
361
|
+
uncovered -= period_regions[best_p]
|
|
362
|
+
return sorted(selected)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _region_scope_forced_indices(
|
|
366
|
+
profiles: dict[str, list[tuple[str, float]]],
|
|
367
|
+
inflows: dict[str, list[tuple[str, float]]],
|
|
368
|
+
demand_scalars: dict[str, float],
|
|
369
|
+
timestep_keys: list[str],
|
|
370
|
+
period_length: int,
|
|
371
|
+
n_base: int,
|
|
372
|
+
*,
|
|
373
|
+
force_peak_load: bool,
|
|
374
|
+
force_highest_net_load: bool,
|
|
375
|
+
force_window: int | None,
|
|
376
|
+
vg_weight: float,
|
|
377
|
+
region_profiles: dict[str, list[str]],
|
|
378
|
+
region_nodes: dict[str, list[str]],
|
|
379
|
+
budget: int | None,
|
|
380
|
+
region_top_k: int = 1,
|
|
381
|
+
) -> list[int] | None:
|
|
382
|
+
"""Per-region budgeted force-include (generic, opt-in).
|
|
383
|
+
|
|
384
|
+
Scores net load *independently per region* — each region's own profiles
|
|
385
|
+
(VG-shortfall term), time-varying inflows, and scalar demand — via the same
|
|
386
|
+
:func:`build_netload_hourly` used system-wide, then :func:`score_net` (when
|
|
387
|
+
``force_highest_net_load``) or :func:`score_peak`. Each region's
|
|
388
|
+
``region_top_k`` worst base periods become its candidate cover set, and
|
|
389
|
+
:func:`_greedy_region_cover` selects the forced periods under ``budget``.
|
|
390
|
+
|
|
391
|
+
This is fully generic: no region names, no period indices, no hemisphere or
|
|
392
|
+
period-length assumptions — the picks emerge from each region's own
|
|
393
|
+
net-load data. It works identically for 3, 18, or 35 regions and any
|
|
394
|
+
``period_length`` / ``n_base``.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
profiles/inflows/demand_scalars/timestep_keys: system-wide series;
|
|
398
|
+
filtered to each region by membership below.
|
|
399
|
+
period_length/n_base: base-period geometry.
|
|
400
|
+
force_peak_load/force_highest_net_load: which per-period scorer to use
|
|
401
|
+
(sustained :func:`score_net` preferred when both set).
|
|
402
|
+
force_window: sub-window for :func:`score_net`.
|
|
403
|
+
vg_weight: convex blend weight on the VG term (per region).
|
|
404
|
+
region_profiles: region -> profile names attached to that region.
|
|
405
|
+
region_nodes: region -> member node names (filters inflow / demand).
|
|
406
|
+
budget: max forced periods; ``None`` covers every region (no cap).
|
|
407
|
+
region_top_k: candidate periods per region (default 1 = its worst lull).
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Sorted forced base-period indices, or ``None`` when no region carries a
|
|
411
|
+
usable signal (caller then falls back to the system-coincident path).
|
|
412
|
+
"""
|
|
413
|
+
use_net = bool(force_highest_net_load)
|
|
414
|
+
region_scores: dict[str, np.ndarray] = {}
|
|
415
|
+
region_candidates: dict[str, list[int]] = {}
|
|
416
|
+
top_k = max(1, int(region_top_k))
|
|
417
|
+
|
|
418
|
+
for region, prof_names in region_profiles.items():
|
|
419
|
+
node_set = set(region_nodes.get(region, []))
|
|
420
|
+
prof_r = {n: profiles[n] for n in prof_names if n in profiles}
|
|
421
|
+
inflow_r = {n: v for n, v in inflows.items() if n in node_set}
|
|
422
|
+
demand_r = {n: v for n, v in demand_scalars.items() if n in node_set}
|
|
423
|
+
# A region with no profile, no inflow and no scalar demand carries no
|
|
424
|
+
# net-load information — skip it (it cannot be scored or covered).
|
|
425
|
+
if not prof_r and not inflow_r and not demand_r:
|
|
426
|
+
continue
|
|
427
|
+
netload_r = build_netload_hourly(
|
|
428
|
+
prof_r,
|
|
429
|
+
inflow_r,
|
|
430
|
+
demand_r,
|
|
431
|
+
timestep_keys,
|
|
432
|
+
vg_weight=vg_weight,
|
|
433
|
+
)
|
|
434
|
+
if use_net:
|
|
435
|
+
scores_r = score_net(netload_r, period_length, n_base, force_window)
|
|
436
|
+
else:
|
|
437
|
+
scores_r = score_peak(netload_r, period_length, n_base)
|
|
438
|
+
# A flat (all-equal) score carries no lull to force — skip so it does
|
|
439
|
+
# not consume budget covering a meaningless argmax.
|
|
440
|
+
if float(scores_r.max() - scores_r.min()) < _SCALE_EPS:
|
|
441
|
+
continue
|
|
442
|
+
region_scores[region] = scores_r
|
|
443
|
+
# Top-k worst periods (descending score); stable for ties via argsort.
|
|
444
|
+
order = np.argsort(scores_r)[::-1]
|
|
445
|
+
region_candidates[region] = [int(order[i]) for i in range(min(top_k, order.size))]
|
|
446
|
+
|
|
447
|
+
if not region_candidates:
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
effective_budget = (
|
|
451
|
+
len(region_candidates) if budget is None else max(0, int(budget))
|
|
452
|
+
)
|
|
453
|
+
return _greedy_region_cover(region_candidates, region_scores, effective_budget)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def compute_forced_indices(
|
|
457
|
+
profiles: dict[str, list[tuple[str, float]]],
|
|
458
|
+
inflows: dict[str, list[tuple[str, float]]],
|
|
459
|
+
demand_scalars: dict[str, float],
|
|
460
|
+
timestep_keys: list[str],
|
|
461
|
+
period_length: int,
|
|
462
|
+
n_base: int,
|
|
463
|
+
*,
|
|
464
|
+
force_peak_load: bool,
|
|
465
|
+
force_highest_net_load: bool,
|
|
466
|
+
force_window: int | None,
|
|
467
|
+
vg_weight: float,
|
|
468
|
+
region_profiles: dict[str, list[str]] | None = None,
|
|
469
|
+
region_demand: dict[str, float] | None = None,
|
|
470
|
+
force_region_scope: bool = False,
|
|
471
|
+
force_region_budget: int | None = None,
|
|
472
|
+
region_nodes: dict[str, list[str]] | None = None,
|
|
473
|
+
) -> list[int]:
|
|
474
|
+
"""Orchestrate net-load scoring and return the forced base-period indices.
|
|
475
|
+
|
|
476
|
+
Two modes:
|
|
477
|
+
|
|
478
|
+
* **System-coincident (default, ``force_region_scope=False``)** — builds one
|
|
479
|
+
system net-load signal (optionally node-group demand-weighted via
|
|
480
|
+
``region_profiles`` / ``region_demand``) and takes the ``argmax`` of every
|
|
481
|
+
enabled flag. This is the byte-parity path; its result is unchanged by the
|
|
482
|
+
region-scope parameters when they are left at their defaults.
|
|
483
|
+
* **Per-region budgeted (``force_region_scope=True``)** — scores net load
|
|
484
|
+
independently per region (:func:`_region_scope_forced_indices`) and
|
|
485
|
+
greedily selects forced periods to cover the most regions' worst lulls
|
|
486
|
+
under ``force_region_budget`` (:func:`_greedy_region_cover`). Requires
|
|
487
|
+
``region_profiles`` + ``region_nodes``; falls back to the system path when
|
|
488
|
+
no region carries a usable signal.
|
|
489
|
+
|
|
490
|
+
Returns the deduplicated, sorted list of base-period indices to
|
|
491
|
+
force-include. Empty list when no flag is set.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
profiles: VRE availability series.
|
|
495
|
+
inflows: Time-varying node inflow series.
|
|
496
|
+
demand_scalars: Constant node inflows collected as demand levels.
|
|
497
|
+
timestep_keys: Ordered timestep keys defining the horizon.
|
|
498
|
+
period_length: Timesteps per aligned base period.
|
|
499
|
+
n_base: Number of aligned base periods.
|
|
500
|
+
force_peak_load: Enable Flag A (peak net load).
|
|
501
|
+
force_highest_net_load: Enable Flag B (sustained net load).
|
|
502
|
+
force_window: Sub-window length for Flag B (``None`` = whole period).
|
|
503
|
+
vg_weight: Convex blend weight on the VG term.
|
|
504
|
+
region_profiles: Optional region -> profile names mapping (demand-weighted
|
|
505
|
+
system term, or per-region VG term under region scope).
|
|
506
|
+
region_demand: Optional region -> demand level ``D_r`` mapping (system
|
|
507
|
+
demand-weighting only).
|
|
508
|
+
force_region_scope: Opt in to the per-region budgeted mode.
|
|
509
|
+
force_region_budget: Max forced periods under region scope (``None`` =
|
|
510
|
+
cover every region).
|
|
511
|
+
region_nodes: Optional region -> member node names mapping; required for
|
|
512
|
+
region scope to filter inflow / demand per region.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
Sorted, deduplicated list of forced base-period indices.
|
|
516
|
+
"""
|
|
517
|
+
if not (force_peak_load or force_highest_net_load):
|
|
518
|
+
return []
|
|
519
|
+
|
|
520
|
+
if force_region_scope:
|
|
521
|
+
if region_profiles and region_nodes:
|
|
522
|
+
region_forced = _region_scope_forced_indices(
|
|
523
|
+
profiles,
|
|
524
|
+
inflows,
|
|
525
|
+
demand_scalars,
|
|
526
|
+
timestep_keys,
|
|
527
|
+
period_length,
|
|
528
|
+
n_base,
|
|
529
|
+
force_peak_load=force_peak_load,
|
|
530
|
+
force_highest_net_load=force_highest_net_load,
|
|
531
|
+
force_window=force_window,
|
|
532
|
+
vg_weight=vg_weight,
|
|
533
|
+
region_profiles=region_profiles,
|
|
534
|
+
region_nodes=region_nodes,
|
|
535
|
+
budget=force_region_budget,
|
|
536
|
+
)
|
|
537
|
+
if region_forced is not None:
|
|
538
|
+
return region_forced
|
|
539
|
+
# No usable region maps → fall through to the system-coincident path
|
|
540
|
+
# (fail safe, never crash) rather than returning nothing.
|
|
541
|
+
print(
|
|
542
|
+
" Force-include: region scope requested but no region carried a "
|
|
543
|
+
"usable per-region signal — falling back to the system aggregate."
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
netload = build_netload_hourly(
|
|
547
|
+
profiles,
|
|
548
|
+
inflows,
|
|
549
|
+
demand_scalars,
|
|
550
|
+
timestep_keys,
|
|
551
|
+
vg_weight=vg_weight,
|
|
552
|
+
region_profiles=region_profiles,
|
|
553
|
+
region_demand=region_demand,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
forced: set[int] = set()
|
|
557
|
+
if force_peak_load:
|
|
558
|
+
forced.add(int(np.argmax(score_peak(netload, period_length, n_base))))
|
|
559
|
+
if force_highest_net_load:
|
|
560
|
+
forced.add(
|
|
561
|
+
int(np.argmax(score_net(netload, period_length, n_base, force_window)))
|
|
562
|
+
)
|
|
563
|
+
return sorted(forced)
|