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,365 @@
1
+ """Pure net-load clustering-matrix math (no database access).
2
+
3
+ Every function here operates on a :class:`~flextool.representative_periods.
4
+ netload_inputs.NetloadInputs` value (built by ``netload_inputs.read_netload_inputs``)
5
+ plus caller-supplied ``timestep_keys`` — mirroring the pure caller-passes-data
6
+ split used by :mod:`flextool.representative_periods.force_include`. This keeps
7
+ each step independently unit-testable.
8
+
9
+ The signal is a real-MW **net load** per aggregation unit ``g``:
10
+
11
+ ``net_load[g, h] = Σ_{demand node in g} demand_h
12
+ − Σ_{VRE unit in g} cap[u] · avail[profile(u)][h]``
13
+
14
+ with ``demand_h = −inflow_h (time-varying) + |scalar level|`` (positive-demand
15
+ convention — a demand node's inflow is negative in FlexTool, so ``−inflow_h`` is
16
+ positive demand and a scalar demand level enters as ``+|value|``; this matches
17
+ ``force_include.build_netload_hourly``). Each aggregation unit's series is then
18
+ min-max normalized to ``[0, 1]`` exactly as
19
+ ``preprocess._build_clustering_matrix`` does per feature.
20
+
21
+ The iteration-0 VRE capacities come from a **demand-match** default: size the
22
+ investable VRE of each aggregation unit so its energy plus the existing VRE
23
+ energy just covers the unit's demand energy (pure energy balance, curtailment
24
+ ignored).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import numpy as np
30
+
31
+ from flextool.representative_periods.netload_inputs import NetloadInputs
32
+
33
+ # Series / availability whose scale is below this are treated as carrying no
34
+ # usable capacity-per-energy information (avoids dividing by a near-zero mean
35
+ # availability).
36
+ _EPS = 1e-12
37
+
38
+
39
+ def _series_lookup(series: list[tuple[str, float]]) -> dict[str, float]:
40
+ """A ``{timestep_key: value}`` lookup from a ``[(key, value), ...]`` list."""
41
+ return {k: float(v) for k, v in series}
42
+
43
+
44
+ def _covers(lookup: dict[str, float], timestep_keys: list[str]) -> bool:
45
+ """Whether ``lookup`` has a value at *every* key in ``timestep_keys``.
46
+
47
+ Matches the partial-coverage policy of ``preprocess._build_clustering_matrix``
48
+ and ``force_include._series_matrix``: a series that does not cover every used
49
+ timestep is skipped by the caller (never zero-filled).
50
+ """
51
+ return all(k in lookup for k in timestep_keys)
52
+
53
+
54
+ def _duration_array(
55
+ step_durations: dict[str, float], timestep_keys: list[str]
56
+ ) -> np.ndarray:
57
+ """Duration per key over ``timestep_keys`` (missing key → duration 1.0)."""
58
+ return np.array(
59
+ [step_durations.get(k, 1.0) for k in timestep_keys], dtype=np.float64
60
+ )
61
+
62
+
63
+ def demand_match_default_caps(
64
+ inputs: NetloadInputs,
65
+ timestep_keys: list[str],
66
+ vre_penetration: float = 1.0,
67
+ ) -> dict[str, float]:
68
+ """Iteration-0 demand-match capacities for every investable VRE unit.
69
+
70
+ For each aggregation unit ``g`` (``inputs.units_by_group``):
71
+
72
+ * ``E_demand = Σ_{node in g}( |scalar| + Σ_h |min(inflow_h, 0)| · dur_h )`` —
73
+ the scalar demand level (magnitude) plus the DURATION-WEIGHTED energy of the
74
+ time-varying *demand* (negative-inflow) part. A time-varying inflow series
75
+ that does not cover every key in ``timestep_keys`` is SKIPPED with a warning
76
+ (never zero-filled), matching ``preprocess._build_clustering_matrix``.
77
+ * ``E_existing_VRE = Σ_{VRE unit in g} existing_cap · Σ_h avail_h · dur_h``
78
+ over ALL of ``g``'s VRE units (investable and not) — the true
79
+ duration-weighted profiled energy, correct for UNEQUAL step durations. A VRE
80
+ unit whose availability profile does not cover every used timestep is
81
+ SKIPPED with a warning (its contribution is not subtracted).
82
+ * ``target = max(0, vre_penetration · E_demand − E_existing_VRE)`` — the
83
+ (scaled) demand energy the existing VRE fleet does not already cover.
84
+ ``vre_penetration`` (default ``1.0`` → 100% energy match) scales the demand
85
+ target: e.g. ``0.5`` sizes the investable fleet to a half-energy VRE share.
86
+ * ``target`` is split as EQUAL ENERGY SHARES across ``g``'s ``k`` investable
87
+ VRE units that have a usable profile energy
88
+ (``w_u = Σ_h avail_h · dur_h ≥ eps``). Each such unit's invested capacity is
89
+ ``(target/k) / w_u``, i.e. the capacity whose profiled energy
90
+ ``cap · Σ_h avail_h · dur_h`` equals its ``target/k`` share EXACTLY.
91
+
92
+ Contract of the returned dict (documented, and pinned by the capacity-contract
93
+ test):
94
+
95
+ * Keys are **investable VRE units only**; existing-only (non-investable)
96
+ units are absent (their capacity is their existing cap, applied by
97
+ :func:`build_group_capacities`).
98
+ * Each value is the unit's **TOTAL** iteration-0 capacity =
99
+ ``existing_cap + invested_share``. Because ``target`` already subtracts the
100
+ existing VRE energy, adding it back per unit keeps the group's total VRE
101
+ energy equal to ``vre_penetration · E_demand`` (when ``target > 0`` and
102
+ every investable unit is usable) — a clean, energy-consistent total the
103
+ net-load builder can multiply by availability directly. The invested VRE
104
+ energy ``Σ_u invested_u · w_u`` equals ``target`` to machine precision, for
105
+ equal AND unequal step durations alike.
106
+ * An investable unit with ``w_u < eps`` (no usable profile), or a group whose
107
+ ``target`` is 0 or has no usable investable unit, gets its ``existing_cap``
108
+ (no useful invest possible) — never a divide-by-near-zero blow-up.
109
+
110
+ Curtailment is ignored (pure energy balance).
111
+ """
112
+ dur = _duration_array(inputs.step_durations, timestep_keys)
113
+ caps: dict[str, float] = {}
114
+
115
+ for group in sorted(inputs.units_by_group):
116
+ node_set = set(inputs.units_by_group[group])
117
+
118
+ # Demand energy of the group (duration-weighted).
119
+ e_demand = 0.0
120
+ for node in sorted(node_set):
121
+ e_demand += abs(inputs.demand_scalar.get(node, 0.0))
122
+ ts = inputs.demand_ts.get(node)
123
+ if not ts:
124
+ continue
125
+ lookup = _series_lookup(ts)
126
+ if not _covers(lookup, timestep_keys):
127
+ print(
128
+ f" Net-load: skipping inflow of node '{node}' "
129
+ f"(demand-match): does not cover all used timesteps."
130
+ )
131
+ continue
132
+ inflow = np.array(
133
+ [lookup[k] for k in timestep_keys], dtype=np.float64
134
+ )
135
+ demand_part = np.where(inflow < 0.0, -inflow, 0.0)
136
+ e_demand += float(np.dot(demand_part, dur))
137
+
138
+ # VRE units of the group, with their duration-weighted profile energy.
139
+ vre_units = sorted(
140
+ u for u, vu in inputs.vre.items() if vu.node in node_set
141
+ )
142
+ weight: dict[str, float] = {}
143
+ e_existing = 0.0
144
+ for u in vre_units:
145
+ vu = inputs.vre[u]
146
+ lookup = _series_lookup(inputs.profiles.get(vu.profile, []))
147
+ if not _covers(lookup, timestep_keys):
148
+ print(
149
+ f" Net-load: skipping VRE unit '{u}' (demand-match): "
150
+ f"profile '{vu.profile}' does not cover all used timesteps."
151
+ )
152
+ weight[u] = 0.0
153
+ continue
154
+ avail = np.array(
155
+ [lookup[k] for k in timestep_keys], dtype=np.float64
156
+ )
157
+ w_u = float(np.dot(avail, dur))
158
+ weight[u] = w_u
159
+ e_existing += vu.existing_cap * w_u
160
+
161
+ target = max(0.0, vre_penetration * e_demand - e_existing)
162
+
163
+ # Investable units with usable profile energy share the target energy.
164
+ usable = [
165
+ u
166
+ for u in vre_units
167
+ if inputs.vre[u].investable and weight[u] >= _EPS
168
+ ]
169
+ k = len(usable)
170
+
171
+ for u in vre_units:
172
+ vu = inputs.vre[u]
173
+ if not vu.investable:
174
+ continue # existing-only units are not in this dict.
175
+ w_u = weight[u]
176
+ if k == 0 or target <= 0.0 or w_u < _EPS:
177
+ # No useful invest possible: total cap is just the existing cap.
178
+ caps[u] = vu.existing_cap
179
+ else:
180
+ invested = (target / k) / w_u
181
+ caps[u] = vu.existing_cap + invested
182
+
183
+ return {u: caps[u] for u in sorted(caps)}
184
+
185
+
186
+ def build_group_capacities(
187
+ inputs: NetloadInputs,
188
+ default_caps: dict[str, float],
189
+ solved_caps: dict[str, float] | None,
190
+ ) -> dict[str, float]:
191
+ """Resolve the per-VRE-unit capacity used to build the net-load signal.
192
+
193
+ Contract (existing vs default vs solved), one rule per VRE unit:
194
+
195
+ * **Non-investable (existing-only)** unit → always its ``existing_cap``. Its
196
+ capacity is fixed data; neither the demand-match default nor a solve can
197
+ change it.
198
+ * **Investable** unit → the *total* capacity for this iteration:
199
+ ``solved_caps[unit]`` when ``solved_caps`` is provided and contains the
200
+ unit (a later iteration feeding back a solve's realized capacity), else
201
+ ``default_caps[unit]`` (the iteration-0 demand-match total from
202
+ :func:`demand_match_default_caps`). Both sources are totals, so no existing
203
+ capacity is added here. A unit absent from the chosen source falls back to
204
+ its ``existing_cap`` (defensive; the demand-match default always emits
205
+ every investable unit).
206
+
207
+ Returns a capacity for every VRE unit in ``inputs.vre`` (sorted).
208
+ """
209
+ caps: dict[str, float] = {}
210
+ for u in sorted(inputs.vre):
211
+ vu = inputs.vre[u]
212
+ if not vu.investable:
213
+ caps[u] = vu.existing_cap
214
+ continue
215
+ if solved_caps is not None and u in solved_caps:
216
+ caps[u] = float(solved_caps[u])
217
+ elif u in default_caps:
218
+ caps[u] = float(default_caps[u])
219
+ else:
220
+ caps[u] = vu.existing_cap
221
+ return caps
222
+
223
+
224
+ def build_netload_matrix(
225
+ inputs: NetloadInputs,
226
+ caps: dict[str, float],
227
+ timestep_keys: list[str],
228
+ period_length: int,
229
+ ) -> tuple[np.ndarray, int, list[str]]:
230
+ """Build the net-load clustering matrix ``C`` from per-unit capacities.
231
+
232
+ For each aggregation unit ``g`` (sorted): compute the hourly net load
233
+ ``demand_h − Σ VRE cap·avail_h``, min-max normalize the series to ``[0, 1]``
234
+ (same convention as ``preprocess._build_clustering_matrix``; a constant /
235
+ all-zero series normalizes to all zeros and is KEPT — it simply carries no
236
+ information), reshape to ``(n_base_periods, period_length)``, and stack.
237
+
238
+ Period geometry copies ``preprocess._build_clustering_matrix``: drop the
239
+ tail so ``n_base_periods = len(timestep_keys) // period_length`` whole
240
+ periods remain (a positive drop prints a warning; ``n_base_periods == 0``
241
+ raises).
242
+
243
+ Args:
244
+ inputs: The net-load inputs (aggregation units, demand, VRE, profiles).
245
+ caps: Per-VRE-unit capacity (from :func:`build_group_capacities`); a unit
246
+ absent from the map is treated as 0 capacity.
247
+ timestep_keys: Ordered timestep keys defining the horizon.
248
+ period_length: Timesteps per aligned base period.
249
+
250
+ Returns:
251
+ ``(C, n_base_periods, agg_unit_names)`` where ``C`` has shape
252
+ ``(n_agg · period_length, n_base_periods)`` and ``agg_unit_names`` is the
253
+ sorted list of aggregation-unit names (block order along ``C``'s rows).
254
+
255
+ Raises:
256
+ ValueError: If ``n_base_periods == 0``, or if there are no aggregation
257
+ units at all (mirrors ``_build_clustering_matrix``'s "no valid time
258
+ series" guard).
259
+ """
260
+ n_total = len(timestep_keys)
261
+ n_base_periods = n_total // period_length
262
+ n_dropped = n_total - n_base_periods * period_length
263
+
264
+ if n_base_periods == 0:
265
+ raise ValueError(
266
+ f"Timeline has {n_total} timesteps but period_length is "
267
+ f"{period_length}. Need at least {period_length} timesteps."
268
+ )
269
+ if n_dropped > 0:
270
+ print(f"Warning: Dropping {n_dropped} timesteps from end of timeline")
271
+
272
+ n_used = n_base_periods * period_length
273
+ used_keys = timestep_keys[:n_used]
274
+
275
+ agg_names = sorted(inputs.units_by_group)
276
+ if not agg_names:
277
+ raise ValueError("No aggregation units found for net-load clustering.")
278
+
279
+ # Pre-build availability arrays (over the used keys) for every profile
280
+ # referenced by a VRE unit. A profile that does not cover all used timesteps
281
+ # is cached as ``None`` (skip policy of ``preprocess._build_clustering_matrix``
282
+ # / ``force_include._series_matrix`` — never zero-filled).
283
+ avail_cache: dict[str, np.ndarray | None] = {}
284
+
285
+ def _avail(profile: str) -> np.ndarray | None:
286
+ if profile not in avail_cache:
287
+ lookup = _series_lookup(inputs.profiles.get(profile, []))
288
+ if _covers(lookup, used_keys):
289
+ avail_cache[profile] = np.array(
290
+ [lookup[k] for k in used_keys], dtype=np.float64
291
+ )
292
+ else:
293
+ avail_cache[profile] = None
294
+ return avail_cache[profile]
295
+
296
+ feature_blocks: list[np.ndarray] = []
297
+ for group in agg_names:
298
+ node_set = set(inputs.units_by_group[group])
299
+
300
+ # Demand term: Σ nodes ( −inflow_h(time-varying) + |scalar| ).
301
+ demand = np.zeros(n_used, dtype=np.float64)
302
+ has_demand = False
303
+ for node in sorted(node_set):
304
+ scalar = inputs.demand_scalar.get(node, 0.0)
305
+ if scalar:
306
+ demand += abs(scalar)
307
+ has_demand = True
308
+ ts = inputs.demand_ts.get(node)
309
+ if ts:
310
+ lookup = _series_lookup(ts)
311
+ if not _covers(lookup, used_keys):
312
+ print(
313
+ f" Net-load: skipping inflow of node '{node}': "
314
+ f"does not cover all used timesteps."
315
+ )
316
+ continue
317
+ demand -= np.array(
318
+ [lookup[k] for k in used_keys], dtype=np.float64
319
+ )
320
+ has_demand = True
321
+
322
+ # VRE term: Σ VRE units cap · avail_h.
323
+ vre_supply = np.zeros(n_used, dtype=np.float64)
324
+ has_vre = False
325
+ for u in sorted(u for u, vu in inputs.vre.items() if vu.node in node_set):
326
+ has_vre = True
327
+ avail = _avail(inputs.vre[u].profile)
328
+ if avail is None:
329
+ print(
330
+ f" Net-load: skipping VRE unit '{u}': profile "
331
+ f"'{inputs.vre[u].profile}' does not cover all used timesteps."
332
+ )
333
+ continue
334
+ cap = float(caps.get(u, 0.0))
335
+ if cap:
336
+ vre_supply += cap * avail
337
+
338
+ # Co-location check: a group with VRE capacity but NO demand (neither
339
+ # time-varying inflow nor a scalar level) yields a pure-negative "net
340
+ # load" with nothing to net against — usually the region's load lives on
341
+ # a different node/group. Warn and suggest a co-locating region-group.
342
+ # (Demand-but-no-VRE, a pure load region, is normal and NOT warned.)
343
+ if has_vre and not has_demand:
344
+ print(
345
+ f" Net-load: aggregation unit '{group}' has VRE capacity but "
346
+ f"no demand (time-varying or scalar) — its net load is pure "
347
+ f"negative VRE. Define a region-group "
348
+ f"(use_for_representative_periods) to co-locate demand and VRE."
349
+ )
350
+
351
+ series = demand - vre_supply
352
+
353
+ # Min-max normalize to [0, 1]; a constant series → all zeros (kept).
354
+ s_min = series.min()
355
+ s_max = series.max()
356
+ if s_max > s_min:
357
+ series = (series - s_min) / (s_max - s_min)
358
+ else:
359
+ series = np.zeros_like(series)
360
+
361
+ feature_blocks.append(series.reshape(n_base_periods, period_length))
362
+
363
+ feature_matrix = np.hstack(feature_blocks) # (n_base, n_agg * PL)
364
+ C = feature_matrix.T # (n_agg * PL, n_base)
365
+ return C, n_base_periods, agg_names