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.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. 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)