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,948 @@
1
+ """Main orchestrator for representative periods pre-processing.
2
+
3
+ Reads time series from a FlexTool Spine database, runs greedy convex hull
4
+ clustering, computes convex weights, and writes results back to the database.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ import numpy as np
13
+ import spinedb_api as api
14
+ from spinedb_api import DatabaseMapping, import_data, Map
15
+
16
+ from flextool.engine_polars._db_reader import DictMode, params_to_dict
17
+ from flextool.representative_periods import force_include
18
+ from flextool.representative_periods.clustering import greedy_convex_hull_clustering
19
+ from flextool.representative_periods.netload import (
20
+ build_group_capacities,
21
+ build_netload_matrix,
22
+ demand_match_default_caps,
23
+ )
24
+ from flextool.representative_periods.netload_inputs import read_netload_inputs
25
+ from flextool.representative_periods.weights import compute_weight_matrix
26
+
27
+
28
+ def _read_time_series(
29
+ db: DatabaseMapping,
30
+ ) -> tuple[
31
+ dict[str, list[tuple[str, float]]],
32
+ dict[str, list[tuple[str, float]]],
33
+ dict[str, float],
34
+ ]:
35
+ """Read profile and inflow time series from the database.
36
+
37
+ Returns:
38
+ Tuple of ``(profiles, inflows, demand_scalars)``. ``profiles`` and
39
+ ``inflows`` each map entity name to a list of ``(timestep_key, value)``
40
+ pairs (the time-varying series that feed clustering). ``demand_scalars``
41
+ maps node name to its *scalar* (constant) inflow value as a float — the
42
+ constant demand levels dropped from clustering but needed by
43
+ force-include as per-region demand weights ``D_r``.
44
+
45
+ Time-varying series are the ``isinstance(v, list)`` entries.
46
+ ``params_to_dict`` returns a scalar float as a *string* (see
47
+ ``db_reader.py`` ``params_to_dict`` line ~117), and the downstream
48
+ clustering matrix builder iterates each entry's value as
49
+ ``for k, v in ts_data:`` which crashes on the per-character unpack, so
50
+ scalars are excluded from ``inflows``. A constant-inflow node has zero
51
+ variance so it would be skipped by the constant-series filter further down
52
+ anyway; dropping it here makes the skip explicit and matches the existing
53
+ "no matching timesteps" semantics. The scalar inflows are instead collected
54
+ into ``demand_scalars`` (coerced from the string via ``float(...)``, skipping
55
+ any value that does not coerce).
56
+ """
57
+ raw_profiles = params_to_dict(
58
+ db=db, cl="profile", par="profile", mode=DictMode.DICT
59
+ )
60
+ raw_inflows = params_to_dict(
61
+ db=db, cl="node", par="inflow", mode=DictMode.DICT
62
+ )
63
+ profiles: dict[str, list[tuple[str, float]]] = {
64
+ k: v for k, v in raw_profiles.items() if isinstance(v, list)
65
+ }
66
+ inflows: dict[str, list[tuple[str, float]]] = {
67
+ k: v for k, v in raw_inflows.items() if isinstance(v, list)
68
+ }
69
+ # Collect the dropped scalar inflows as demand levels. A scalar comes back
70
+ # as a string from params_to_dict; coerce and skip anything non-numeric.
71
+ demand_scalars: dict[str, float] = {}
72
+ for k, v in raw_inflows.items():
73
+ if isinstance(v, list):
74
+ continue
75
+ try:
76
+ demand_scalars[k] = float(v)
77
+ except (TypeError, ValueError):
78
+ continue
79
+ dropped_profiles = len(raw_profiles) - len(profiles)
80
+ dropped_inflows = len(raw_inflows) - len(inflows)
81
+ if dropped_profiles or dropped_inflows:
82
+ print(
83
+ f" Skipping {dropped_profiles} scalar profile(s) and "
84
+ f"{dropped_inflows} scalar inflow(s) — clustering only "
85
+ f"considers time-varying series."
86
+ )
87
+ return profiles, inflows, demand_scalars
88
+
89
+
90
+ def _read_region_maps(
91
+ db: DatabaseMapping,
92
+ region_groups: list[str],
93
+ demand_scalars: dict[str, float],
94
+ ) -> tuple[dict[str, list[str]], dict[str, float], dict[str, list[str]]]:
95
+ """Build node-group demand-weighting maps for force-include (structural).
96
+
97
+ For each named region group, resolve its member nodes via ``group__node``
98
+ membership, then:
99
+
100
+ * ``region_profiles[r]`` = profile names attached (via ``unit__node__profile``)
101
+ to a node that is a member of region ``r``.
102
+ * ``region_demand[r]`` = ``Σ|demand_scalars[node]|`` over region ``r``'s
103
+ member nodes (the scalar demand already collected by ``_read_time_series``).
104
+ * ``region_nodes[r]`` = the member node names of region ``r`` (used by the
105
+ per-region force-include mode to filter inflow / demand series per region).
106
+
107
+ Purely structural — no name parsing. ``group__node`` ``element_name_list`` is
108
+ ``(group, node)`` (dimension order group=0, node=1); ``unit__node__profile``
109
+ is ``(unit, node, profile)`` (node=1, profile=2).
110
+
111
+ Args:
112
+ db: Scenario-filtered database mapping.
113
+ region_groups: Names of the region node-groups to weight by.
114
+ demand_scalars: Node name -> scalar demand level (from ``_read_time_series``).
115
+
116
+ Returns:
117
+ Tuple of ``(region_profiles, region_demand, region_nodes)``.
118
+ """
119
+ wanted = set(region_groups)
120
+
121
+ # region -> set of member node names
122
+ region_node_sets: dict[str, set[str]] = {r: set() for r in region_groups}
123
+ for item in db.get_entity_items(entity_class_name="group__node"):
124
+ group_name, node_name = item["element_name_list"]
125
+ if group_name in wanted:
126
+ region_node_sets[group_name].add(node_name)
127
+
128
+ # node -> profiles attached to it
129
+ node_profiles: dict[str, list[str]] = {}
130
+ for item in db.get_entity_items(entity_class_name="unit__node__profile"):
131
+ _unit, node_name, profile_name = item["element_name_list"]
132
+ node_profiles.setdefault(node_name, []).append(profile_name)
133
+
134
+ region_profiles: dict[str, list[str]] = {}
135
+ region_demand: dict[str, float] = {}
136
+ region_nodes: dict[str, list[str]] = {}
137
+ for region in region_groups:
138
+ nodes = region_node_sets[region]
139
+ profs: list[str] = []
140
+ for node_name in nodes:
141
+ profs.extend(node_profiles.get(node_name, []))
142
+ region_profiles[region] = profs
143
+ region_nodes[region] = sorted(nodes)
144
+ region_demand[region] = sum(
145
+ abs(demand_scalars[n]) for n in nodes if n in demand_scalars
146
+ )
147
+ print(
148
+ f" Region '{region}': {len(nodes)} member node(s), "
149
+ f"{len(profs)} profile(s), D_r = {region_demand[region]:.3f}"
150
+ )
151
+
152
+ return region_profiles, region_demand, region_nodes
153
+
154
+
155
+ def _get_timeline_keys(db: DatabaseMapping) -> list[str]:
156
+ """Determine the main timeline and return ordered timestep keys.
157
+
158
+ Reads all timelines and returns the timestep keys from the first one found.
159
+ """
160
+ timelines = params_to_dict(
161
+ db=db, cl="timeline", par="timestep_duration", mode=DictMode.DICT
162
+ )
163
+ if not timelines:
164
+ raise ValueError("No timeline found in the database.")
165
+
166
+ # Use the first timeline available
167
+ timeline_name = next(iter(timelines))
168
+ timeline_data = timelines[timeline_name]
169
+ print(f"Using timeline: '{timeline_name}' with {len(timeline_data)} timesteps")
170
+
171
+ # timeline_data is a list of (timestep_key, duration) pairs
172
+ timestep_keys = [entry[0] for entry in timeline_data]
173
+ return timestep_keys
174
+
175
+
176
+ def _build_clustering_matrix(
177
+ profiles: dict[str, list[tuple[str, float]]],
178
+ inflows: dict[str, list[tuple[str, float]]],
179
+ timestep_keys: list[str],
180
+ period_length: int,
181
+ ) -> tuple[np.ndarray, int]:
182
+ """Build the clustering matrix from time series data.
183
+
184
+ Each time series is split into periods, normalized to [0,1],
185
+ and stacked as features.
186
+
187
+ Args:
188
+ profiles: Profile time series (name -> [(key, value), ...]).
189
+ inflows: Inflow time series (name -> [(key, value), ...]).
190
+ timestep_keys: Ordered list of timestep keys from the timeline.
191
+ period_length: Number of timesteps per period.
192
+
193
+ Returns:
194
+ Tuple of (C, n_base_periods) where C has shape
195
+ (n_features * period_length, n_base_periods).
196
+ """
197
+ n_total = len(timestep_keys)
198
+ n_base_periods = n_total // period_length
199
+ n_dropped = n_total - n_base_periods * period_length
200
+
201
+ if n_base_periods == 0:
202
+ raise ValueError(
203
+ f"Timeline has {n_total} timesteps but period_length is {period_length}. "
204
+ f"Need at least {period_length} timesteps."
205
+ )
206
+
207
+ if n_dropped > 0:
208
+ print(f"Warning: Dropping {n_dropped} timesteps from end of timeline")
209
+
210
+ n_used = n_base_periods * period_length
211
+ key_set = set(timestep_keys[:n_used])
212
+
213
+ # Collect all time series into a list of 1D arrays
214
+ all_series: list[np.ndarray] = []
215
+ series_names: list[str] = []
216
+
217
+ combined_ts: dict[str, list[tuple[str, float]]] = {}
218
+ for name, data in profiles.items():
219
+ combined_ts[f"profile:{name}"] = data
220
+ for name, data in inflows.items():
221
+ combined_ts[f"inflow:{name}"] = data
222
+
223
+ for ts_name, ts_data in combined_ts.items():
224
+ # Build a lookup from key to value
225
+ ts_dict = {k: v for k, v in ts_data}
226
+
227
+ # Check if this time series covers the timeline
228
+ available_keys = key_set & ts_dict.keys()
229
+ if len(available_keys) < n_used:
230
+ if len(available_keys) == 0:
231
+ print(f" Skipping '{ts_name}': no matching timesteps")
232
+ continue
233
+ print(
234
+ f" Warning: Skipping '{ts_name}': only {len(available_keys)}/{n_used} "
235
+ f"timesteps available"
236
+ )
237
+ continue
238
+
239
+ # Extract values in timeline order
240
+ values = np.array([ts_dict[k] for k in timestep_keys[:n_used]], dtype=np.float64)
241
+
242
+ # Skip constant time series (no variation, adds no information)
243
+ val_min = values.min()
244
+ val_max = values.max()
245
+ if val_max == val_min:
246
+ print(f" Skipping '{ts_name}': constant value ({val_min})")
247
+ continue
248
+
249
+ # Normalize to [0, 1]
250
+ values = (values - val_min) / (val_max - val_min)
251
+
252
+ all_series.append(values)
253
+ series_names.append(ts_name)
254
+
255
+ if not all_series:
256
+ raise ValueError("No valid time series found for clustering.")
257
+
258
+ print(f"Using {len(all_series)} time series features for clustering")
259
+ for name in series_names:
260
+ print(f" - {name}")
261
+
262
+ # Stack features: each base period d has a feature vector of length
263
+ # (n_features * period_length)
264
+ # Reshape each series to (n_base_periods, period_length) then stack
265
+ feature_blocks: list[np.ndarray] = []
266
+ for values in all_series:
267
+ # Reshape to (n_base_periods, period_length)
268
+ reshaped = values[:n_used].reshape(n_base_periods, period_length)
269
+ feature_blocks.append(reshaped)
270
+
271
+ # Stack along feature dimension: (n_base_periods, n_features * period_length)
272
+ feature_matrix = np.hstack(feature_blocks) # (n_base_periods, n_features * period_length)
273
+
274
+ # Transpose to get C of shape (n_features * period_length, n_base_periods)
275
+ C = feature_matrix.T
276
+
277
+ print(
278
+ f"Clustering matrix shape: {C.shape} "
279
+ f"({len(all_series)} features x {period_length} timesteps, {n_base_periods} periods)"
280
+ )
281
+
282
+ return C, n_base_periods
283
+
284
+
285
+ def _build_timeset_duration_map(
286
+ rep_indices: list[int],
287
+ timestep_keys: list[str],
288
+ period_length: int,
289
+ ) -> Map:
290
+ """Build a Map of representative period start keys to period durations."""
291
+ keys = [timestep_keys[idx * period_length] for idx in rep_indices]
292
+ values = [float(period_length)] * len(rep_indices)
293
+ return Map(keys, values)
294
+
295
+
296
+ def _build_weights_map(
297
+ W: np.ndarray,
298
+ rep_indices: list[int],
299
+ timestep_keys: list[str],
300
+ period_length: int,
301
+ n_base_periods: int,
302
+ ) -> Map:
303
+ """Build a nested Map of representative_period_weights.
304
+
305
+ Outer keys = base period starting timesteps.
306
+ Inner keys = representative period starting timesteps.
307
+ Inner values = weights (only non-zero entries are included).
308
+
309
+ Both levels carry an ``index_name`` (``base_period`` / ``representative_period``)
310
+ so the self-describing-xlsx export labels the two index columns
311
+ meaningfully instead of falling back to the stochastic-sheet defaults
312
+ (``forecast`` / ``branch_time``); the names round-trip with the Map.
313
+ """
314
+ rep_start_keys = [timestep_keys[idx * period_length] for idx in rep_indices]
315
+
316
+ outer_keys: list[str] = []
317
+ outer_values: list[Map] = []
318
+
319
+ for d in range(n_base_periods):
320
+ base_start_key = timestep_keys[d * period_length]
321
+
322
+ # Only include non-zero weights (sparse)
323
+ inner_keys: list[str] = []
324
+ inner_values: list[float] = []
325
+ for r_idx, rep_key in enumerate(rep_start_keys):
326
+ weight = float(W[d, r_idx])
327
+ if weight > 1e-10:
328
+ inner_keys.append(rep_key)
329
+ inner_values.append(weight)
330
+
331
+ if inner_keys:
332
+ outer_keys.append(base_start_key)
333
+ outer_values.append(
334
+ Map(inner_keys, inner_values, index_name="representative_period")
335
+ )
336
+
337
+ return Map(outer_keys, outer_values, index_name="base_period")
338
+
339
+
340
+ def _write_results_to_db(
341
+ db_url: str,
342
+ timeset_name: str,
343
+ alternative_name: str,
344
+ timeline_name: str,
345
+ timeset_duration_map: Map,
346
+ weights_map: Map,
347
+ solve_period_timesets: dict,
348
+ alternative_description: str | None = None,
349
+ ) -> None:
350
+ """Write clustering results to the database in a new alternative.
351
+
352
+ Also updates each solve's period_timeset to point to the new RP timeset.
353
+ Opens a NEW connection WITHOUT scenario filter. ``alternative_description``
354
+ overrides the alternative's description text; ``None`` (default) keeps the
355
+ byte-parity ``Representative periods: <timeset>`` string.
356
+ """
357
+ # Clean overwrite (separate connection, committed BEFORE the import below).
358
+ # Spine's ``import_data`` MERGES the indexes of a Map value into any existing
359
+ # value for the same (entity, parameter, alternative) rather than replacing
360
+ # it. Without this purge, re-running the generator into an existing timeset
361
+ # would UNION the fresh representative periods with the stale ones (leaving
362
+ # orphaned periods in ``timeset_duration`` / ``representative_period_weights``).
363
+ # The purge must be its own committed session: a same-session
364
+ # remove-then-import restores the removed item and merges anyway. Scalar
365
+ # params (``timeline``) overwrite cleanly and need no purge.
366
+ with DatabaseMapping(db_url) as db:
367
+ db.fetch_all("parameter_value")
368
+ _purged = False
369
+ for _map_param in ("timeset_duration", "representative_period_weights"):
370
+ try:
371
+ _existing = list(
372
+ db.find_parameter_values(
373
+ entity_class_name="timeset",
374
+ parameter_definition_name=_map_param,
375
+ )
376
+ )
377
+ except Exception:
378
+ # Parameter definition not present yet (fresh DB) → nothing to purge.
379
+ _existing = []
380
+ for _pv in _existing:
381
+ if (
382
+ _pv["entity_name"] == timeset_name
383
+ and _pv["alternative_name"] == alternative_name
384
+ ):
385
+ _pv.remove()
386
+ _purged = True
387
+ if _purged:
388
+ db.commit_session(
389
+ f"Clear stale representative periods before rewrite: {timeset_name}"
390
+ )
391
+
392
+ with DatabaseMapping(db_url) as db:
393
+ # Ensure parameter definition exists for representative_period_weights
394
+ parameter_definitions = [
395
+ ("timeset", "representative_period_weights"),
396
+ ]
397
+
398
+ # Create alternative
399
+ description = (
400
+ alternative_description
401
+ if alternative_description
402
+ else f"Representative periods: {timeset_name}"
403
+ )
404
+ alternatives = [(alternative_name, description)]
405
+
406
+ # Create timeset entity
407
+ entities = [("timeset", timeset_name)]
408
+
409
+ # Set parameter values (5-tuple: class, entity, param, value, alternative)
410
+ parameter_values = [
411
+ ("timeset", timeset_name, "timeline", timeline_name, alternative_name),
412
+ ("timeset", timeset_name, "timeset_duration", timeset_duration_map, alternative_name),
413
+ ("timeset", timeset_name, "representative_period_weights", weights_map, alternative_name),
414
+ ]
415
+
416
+ # Update each solve's period_timeset to use the new RP timeset
417
+ entity_alternatives = [
418
+ ("timeset", timeset_name, alternative_name, True),
419
+ ]
420
+ for solve_name, pts_data in solve_period_timesets.items():
421
+ # pts_data is a list of (period, timeset) tuples or a Map
422
+ if isinstance(pts_data, list):
423
+ periods = [entry[0] for entry in pts_data]
424
+ elif isinstance(pts_data, api.Map):
425
+ periods = list(pts_data.indexes)
426
+ else:
427
+ continue
428
+ # Replace all timesets with the new RP timeset
429
+ new_map = Map(
430
+ [str(p) for p in periods],
431
+ [timeset_name] * len(periods),
432
+ )
433
+ parameter_values.append(
434
+ ("solve", solve_name, "period_timeset", new_map, alternative_name)
435
+ )
436
+ entity_alternatives.append(
437
+ ("solve", solve_name, alternative_name, True)
438
+ )
439
+ print(f" Updated solve '{solve_name}' period_timeset -> '{timeset_name}' for periods {[str(p) for p in periods]}")
440
+
441
+ count, errors = import_data(
442
+ db,
443
+ parameter_definitions=parameter_definitions,
444
+ alternatives=alternatives,
445
+ entities=entities,
446
+ parameter_values=parameter_values,
447
+ entity_alternatives=entity_alternatives,
448
+ )
449
+
450
+ if errors:
451
+ for err in errors:
452
+ print(f" DB import error: {err}")
453
+ raise RuntimeError(f"Failed to write results: {len(errors)} errors")
454
+
455
+ # Tag the lazily-created parameter so it lands under the
456
+ # solve_advanced group in group-filtered exports. No-op on
457
+ # pre-v44 DBs that don't have parameter_groups yet.
458
+ if db.item(db.mapped_table("parameter_group"), name="solve_advanced") is not None:
459
+ db.add_update_item(
460
+ "parameter_definition",
461
+ entity_class_name="timeset",
462
+ name="representative_period_weights",
463
+ parameter_group_name="solve_advanced",
464
+ )
465
+
466
+ db.commit_session(f"Add representative periods: {timeset_name}")
467
+ print(f"Wrote {count} items to database")
468
+
469
+
470
+ def preprocess_representative_periods(
471
+ db_url: str,
472
+ scenario_name: str,
473
+ n_rp: int,
474
+ period_length: int,
475
+ *,
476
+ force_peak_load: bool = False,
477
+ force_highest_net_load: bool = False,
478
+ force_window: int | None = None,
479
+ vg_weight: float = 0.5,
480
+ region_groups: list[str] | None = None,
481
+ force_region_scope: bool = False,
482
+ force_region_budget: int | None = None,
483
+ solves: list[str] | None = None,
484
+ alternative_name: str | None = None,
485
+ alternative_description: str | None = None,
486
+ netload_clustering: bool = False,
487
+ vre_penetration: float = 1.0,
488
+ solved_caps: dict[str, float] | None = None,
489
+ ) -> str:
490
+ """Select representative periods and write results to database.
491
+
492
+ Args:
493
+ db_url: Spine database URL (e.g., 'sqlite:///path.sqlite')
494
+ scenario_name: Name of the scenario to read time series from
495
+ n_rp: Number of representative periods to select
496
+ period_length: Length of each period in timesteps (typically hours)
497
+ force_peak_load: Force-include the peak-net-load base period (Flag A).
498
+ force_highest_net_load: Force-include the sustained-net-load base
499
+ period (Flag B — the energy-adequacy fix).
500
+ force_window: Sub-window length (timesteps) for Flag B; ``None`` means
501
+ the whole-period mean.
502
+ vg_weight: Convex blend weight in [0, 1] on the VG-shortfall term of the
503
+ net-load signal; the inflow-demand term gets ``1 - vg_weight``.
504
+ region_groups: Optional list of node-group names for demand-weighting
505
+ the VG term (``Σ_r D_r · mean_{p∈r}(1 - avail)``). ``None`` (default)
506
+ keeps the unweighted system aggregate — the byte-parity path.
507
+ force_region_scope: Opt in to the per-region budgeted force-include mode
508
+ (requires ``region_groups``). ``False`` (default) keeps the
509
+ single system-coincident signal — the byte-parity path. When ``True``,
510
+ net load is scored independently per region group and the forced set
511
+ is chosen by greedy budgeted coverage of each region's worst lull.
512
+ force_region_budget: Cap on the number of forced periods under region
513
+ scope. ``None`` (default) derives a sane cap from ``n_rp`` as
514
+ ``max(1, n_rp // 2)`` so forced periods never dominate the
515
+ representative set; ignored when ``force_region_scope`` is ``False``.
516
+ solves: Optional subset of solve names whose ``period_timeset`` should
517
+ be repointed at the new RP timeset. ``None`` (default) updates every
518
+ solve that carries a ``period_timeset`` param — the current
519
+ behaviour. A non-``None`` list restricts the update (and the
520
+ per-solve print) to exactly those solves; a name not present in the
521
+ database raises ``ValueError``.
522
+ alternative_name: Optional override for the output alternative name. It
523
+ also names the ``timeset`` entity (kept equal, as in the default
524
+ path), so distinct overrides yield distinct alternatives *and*
525
+ distinct timeset entities — the guard against two scenarios sharing
526
+ an ``n_rp``/``period_length`` colliding on the same names. ``None``
527
+ (default) derives both from ``n_rp``/``period_length`` (plus any
528
+ force suffix), byte-identical to the current behaviour.
529
+ alternative_description: Optional override for the alternative's
530
+ description text. ``None`` (default) keeps the byte-parity
531
+ ``Representative periods: <timeset>`` string.
532
+ netload_clustering: Opt in to the real-MW net-load clustering matrix
533
+ (``netload.build_netload_matrix`` over demand-match-sized VRE caps)
534
+ instead of the per-series normalized profile/inflow stack. ``False``
535
+ (default) keeps ``_build_clustering_matrix`` — the byte-parity path.
536
+ Single-shot only: iteration-0 demand-match capacities, no solve
537
+ feedback (that is Phase 4). When ``True`` the default output name is
538
+ ``netload_{n_rp}rp_{period_length}h`` so it never collides with the
539
+ hull path's ``hull_*`` timeset/alternative.
540
+ vre_penetration: Energy-share target passed to
541
+ ``demand_match_default_caps`` when ``netload_clustering`` is ``True``
542
+ (``1.0`` = full-energy VRE match). Ignored on the default path.
543
+ solved_caps: Optional per-investable-VRE-unit TOTAL capacities from a
544
+ prior solve, threaded into ``build_group_capacities`` so the
545
+ net-load signal is built over the *solved* fleet instead of the
546
+ iteration-0 demand-match default. ``None`` (default) reproduces the
547
+ Phase-3 single-shot behaviour byte-for-byte (demand-match caps
548
+ only). Only consulted when ``netload_clustering`` is ``True``; the
549
+ solve-iteration driver (``netload_iterate``) feeds each iteration's
550
+ invested caps back through this argument.
551
+
552
+ Returns:
553
+ Name of the created timeset entity.
554
+
555
+ With no force flag set (the default) the forced-index set is empty and both
556
+ the selected ``rep_indices`` and the emitted Maps are byte-identical to the
557
+ pure-hull path — this is the opt-in byte-parity contract.
558
+ """
559
+ # ------------------------------------------------------------------
560
+ # 1. Read from DB with scenario filter
561
+ # ------------------------------------------------------------------
562
+ print(f"Reading time series from database (scenario: '{scenario_name}')...")
563
+ scen_config = api.filters.scenario_filter.scenario_filter_config(scenario_name)
564
+ with DatabaseMapping(db_url) as db:
565
+ api.filters.scenario_filter.scenario_filter_from_dict(db, scen_config)
566
+ db.fetch_all("parameter_value")
567
+
568
+ profiles, inflows, demand_scalars = _read_time_series(db)
569
+ print(
570
+ f" Found {len(profiles)} profiles, {len(inflows)} node inflows, "
571
+ f"{len(demand_scalars)} scalar demand level(s)"
572
+ )
573
+
574
+ # ------------------------------------------------------------------
575
+ # 2. Determine timeline
576
+ # ------------------------------------------------------------------
577
+ timestep_keys = _get_timeline_keys(db)
578
+
579
+ # Also read the timeline name for later use
580
+ timelines = params_to_dict(
581
+ db=db, cl="timeline", par="timestep_duration", mode=DictMode.DICT
582
+ )
583
+ timeline_name = next(iter(timelines))
584
+
585
+ # Read solve period_timeset mappings so we can update them
586
+ solve_period_timesets: dict = params_to_dict(
587
+ db=db, cl="solve", par="period_timeset", mode=DictMode.DICT
588
+ )
589
+
590
+ # Optionally restrict the update to a named subset of solves. A
591
+ # requested solve that has no period_timeset in the DB is a user error
592
+ # (typo / wrong scenario) — fail loudly rather than silently no-op.
593
+ if solves is not None:
594
+ missing = [s for s in solves if s not in solve_period_timesets]
595
+ if missing:
596
+ raise ValueError(
597
+ f"--solves named solve(s) with no period_timeset in the "
598
+ f"database: {missing}. Available solves: "
599
+ f"{sorted(solve_period_timesets)}."
600
+ )
601
+ solve_period_timesets = {
602
+ s: solve_period_timesets[s] for s in solves
603
+ }
604
+
605
+ # Node-group demand-weighting / per-region maps (only when region
606
+ # groups requested).
607
+ region_profiles: dict[str, list[str]] | None = None
608
+ region_demand: dict[str, float] | None = None
609
+ region_nodes: dict[str, list[str]] | None = None
610
+ if region_groups:
611
+ region_profiles, region_demand, region_nodes = _read_region_maps(
612
+ db, region_groups, demand_scalars
613
+ )
614
+
615
+ # Net-load inputs are read INSIDE the scenario filter so the builder
616
+ # sees scenario-specific existing caps / invest_method / group flag —
617
+ # the same filtered mapping the profile/inflow reads use above. The
618
+ # returned NetloadInputs dataclass captures the data, so it survives
619
+ # after the mapping closes.
620
+ netload_inputs = read_netload_inputs(db) if netload_clustering else None
621
+
622
+ # ------------------------------------------------------------------
623
+ # 3. Build clustering matrix
624
+ # ------------------------------------------------------------------
625
+ print("Building clustering matrix...")
626
+ if netload_clustering:
627
+ # Net-load mode: size the investable VRE fleet by the iteration-0
628
+ # demand-match default UNLESS the caller feeds back a prior solve's
629
+ # capacities via ``solved_caps`` (the solve-iteration driver), then
630
+ # build the real-MW net-load matrix. Same C shape
631
+ # (n_features · period_length, n_base_periods) the hull path emits, so
632
+ # everything downstream (clustering, force-include seeding, weights,
633
+ # DB write) is unchanged.
634
+ default_caps = demand_match_default_caps(
635
+ netload_inputs, timestep_keys, vre_penetration
636
+ )
637
+ caps = build_group_capacities(
638
+ netload_inputs, default_caps, solved_caps=solved_caps
639
+ )
640
+ C, n_base_periods, _agg_names = build_netload_matrix(
641
+ netload_inputs, caps, timestep_keys, period_length
642
+ )
643
+ else:
644
+ C, n_base_periods = _build_clustering_matrix(
645
+ profiles, inflows, timestep_keys, period_length
646
+ )
647
+
648
+ # ------------------------------------------------------------------
649
+ # 4. Force-include adequacy-critical base periods (opt-in) — computed
650
+ # BEFORE clustering so the greedy hull can be SEEDED with them.
651
+ # ------------------------------------------------------------------
652
+ # Under region scope, resolve the None budget to an n_rp-derived cap so
653
+ # forced periods never dominate the representative set (documented default).
654
+ effective_region_budget = force_region_budget
655
+ if force_region_scope and effective_region_budget is None:
656
+ effective_region_budget = max(1, n_rp // 2)
657
+ # Force-include composes independently of the clustering matrix: it operates
658
+ # on the same profiles/inflows/demand_scalars, so it runs unchanged in
659
+ # netload mode. NOTE: force-include's net-load definition here
660
+ # (``force_include.build_netload_hourly``, a capacity-UNWEIGHTED
661
+ # ``1 − mean(avail)`` VG signal) DIFFERS from the capacity-weighted real-MW
662
+ # net load the netload clustering matrix uses. Unifying the two into one
663
+ # signal is a deliberate open question left to a later phase; for now each
664
+ # keeps its own definition on purpose.
665
+ forced_indices = force_include.compute_forced_indices(
666
+ profiles,
667
+ inflows,
668
+ demand_scalars,
669
+ timestep_keys,
670
+ period_length,
671
+ n_base_periods,
672
+ force_peak_load=force_peak_load,
673
+ force_highest_net_load=force_highest_net_load,
674
+ force_window=force_window,
675
+ vg_weight=vg_weight,
676
+ region_profiles=region_profiles,
677
+ region_demand=region_demand,
678
+ force_region_scope=force_region_scope,
679
+ force_region_budget=effective_region_budget,
680
+ region_nodes=region_nodes,
681
+ )
682
+
683
+ # ------------------------------------------------------------------
684
+ # 4b. Run clustering (seeded by the forced periods when forcing is active)
685
+ # ------------------------------------------------------------------
686
+ # The pure (unseeded) hull is always computed: with no force flag it IS the
687
+ # representative set (byte-parity default path); when forcing is active it is
688
+ # only the naming baseline (how many forced periods displaced a clustered
689
+ # pick). Empty forced set → rep_indices == pure_hull → byte-parity default.
690
+ print(
691
+ f"Running greedy convex hull clustering (selecting {n_rp} from "
692
+ f"{n_base_periods} periods)..."
693
+ )
694
+ pure_hull = sorted(greedy_convex_hull_clustering(C, n_rp))
695
+ print(f"Selected representative period indices: {pure_hull}")
696
+
697
+ if not forced_indices:
698
+ rep_indices = pure_hull
699
+ n_forced = 0
700
+ else:
701
+ # Seed the greedy hull with the forced periods so the remaining
702
+ # (n_rp - |forced|) picks are chosen to COMPLEMENT them — the clustering
703
+ # never spends budget on a period a forced extreme already represents.
704
+ # The total stays pinned at n_rp: forced periods consume part of the
705
+ # budget, they never grow it beyond n_rp.
706
+ rep_indices = sorted(
707
+ greedy_convex_hull_clustering(C, n_rp, seed_indices=forced_indices)
708
+ )
709
+ # Suffix count = forced periods that displaced a pure-hull pick (i.e.
710
+ # were not already selected by the unseeded clustering). Zero leaves the
711
+ # pure-hull name untouched, preserving the default-path name.
712
+ n_forced = len(set(forced_indices) - set(pure_hull))
713
+
714
+ if forced_indices:
715
+ forced_start_keys = [
716
+ timestep_keys[idx * period_length] for idx in forced_indices
717
+ ]
718
+ print(
719
+ f"Force-include (seeded): forced base periods "
720
+ f"{forced_indices} (starts {forced_start_keys}); "
721
+ f"{n_forced} displaced a clustered pick."
722
+ )
723
+ print(f"Final representative period indices: {rep_indices}")
724
+
725
+ # ------------------------------------------------------------------
726
+ # 5. Compute weights
727
+ # ------------------------------------------------------------------
728
+ print("Computing convex weights...")
729
+ W = compute_weight_matrix(C, rep_indices)
730
+
731
+ # Compute projection errors for summary
732
+ R = C[:, rep_indices]
733
+ errors = np.array([
734
+ np.linalg.norm(R @ W[d, :] - C[:, d]) for d in range(n_base_periods)
735
+ ])
736
+ mean_error = float(errors.mean())
737
+ max_error = float(errors.max())
738
+
739
+ # ------------------------------------------------------------------
740
+ # 6. Build output
741
+ # ------------------------------------------------------------------
742
+ # Naming rule: base is the pure-hull name ``hull_{n_rp}rp_{PL}h``. Append a
743
+ # ``+f{n_forced}`` suffix whenever forced periods displaced a clustered pick
744
+ # (``n_forced > 0`` = forced periods the unseeded hull would not have
745
+ # selected). When ``n_forced == 0`` (default, or a forced index that was
746
+ # already a hull pick) the name is unchanged, preserving byte-parity of the
747
+ # default path and not overwriting the pure-hull timeset.
748
+ suffix = f"+f{n_forced}" if n_forced > 0 else ""
749
+ # Net-load mode gets a distinct ``netload_*`` prefix so its timeset /
750
+ # alternative never collides with the hull path's ``hull_*`` name.
751
+ name_prefix = "netload" if netload_clustering else "hull"
752
+ default_name = f"{name_prefix}_{n_rp}rp_{period_length}h{suffix}"
753
+ if alternative_name is None:
754
+ # Byte-parity default: derive both names from n_rp/period_length.
755
+ alternative_name = default_name
756
+ # Tie the timeset ENTITY name to the alternative so distinct callers never
757
+ # collide on the entity either (the entity is not alternative-scoped).
758
+ timeset_name = alternative_name
759
+
760
+ timeset_duration_map = _build_timeset_duration_map(
761
+ rep_indices, timestep_keys, period_length
762
+ )
763
+ weights_map = _build_weights_map(
764
+ W, rep_indices, timestep_keys, period_length, n_base_periods
765
+ )
766
+
767
+ # ------------------------------------------------------------------
768
+ # 7. Write to DB (new connection, no scenario filter)
769
+ # ------------------------------------------------------------------
770
+ print("Writing results to database...")
771
+ _write_results_to_db(
772
+ db_url,
773
+ timeset_name,
774
+ alternative_name,
775
+ timeline_name,
776
+ timeset_duration_map,
777
+ weights_map,
778
+ solve_period_timesets,
779
+ alternative_description=alternative_description,
780
+ )
781
+
782
+ # ------------------------------------------------------------------
783
+ # 8. Print summary
784
+ # ------------------------------------------------------------------
785
+ print("\n--- Representative Periods Summary ---")
786
+ print(f" Representative periods selected: {n_rp}")
787
+ print(f" Mean projection error: {mean_error:.6f}")
788
+ print(f" Max projection error: {max_error:.6f}")
789
+ print(f" Timeset name: '{timeset_name}'")
790
+ print(f" Alternative name: '{alternative_name}'")
791
+
792
+ rep_start_keys = [timestep_keys[idx * period_length] for idx in rep_indices]
793
+ print(f" Representative period starts: {rep_start_keys}")
794
+
795
+ return timeset_name
796
+
797
+
798
+ def main() -> None:
799
+ """CLI entry point for representative periods preprocessing."""
800
+ parser = argparse.ArgumentParser(
801
+ description="Select representative periods using greedy convex hull clustering "
802
+ "and write results to a FlexTool Spine database.",
803
+ )
804
+ parser.add_argument(
805
+ "db_url",
806
+ help="Spine database URL (e.g., 'sqlite:///path/to/db.sqlite')",
807
+ )
808
+ parser.add_argument(
809
+ "scenario",
810
+ help="Name of the scenario to read time series from",
811
+ )
812
+ parser.add_argument(
813
+ "n_rp",
814
+ type=int,
815
+ help="Number of representative periods to select",
816
+ )
817
+ parser.add_argument(
818
+ "period_length",
819
+ type=int,
820
+ help="Length of each period in timesteps (e.g., 24 for daily, 168 for weekly)",
821
+ )
822
+ parser.add_argument(
823
+ "--force-peak-load",
824
+ action="store_true",
825
+ help="Force-include the base period with the highest instantaneous "
826
+ "net load (Flag A, capacity-adequacy).",
827
+ )
828
+ parser.add_argument(
829
+ "--force-highest-net-load",
830
+ action="store_true",
831
+ help="Force-include the base period with the greatest sustained net "
832
+ "load (Flag B, energy-adequacy — the multi-carrier fix).",
833
+ )
834
+ parser.add_argument(
835
+ "--force-window",
836
+ type=int,
837
+ default=None,
838
+ help="Sub-window length in timesteps for the sustained net-load score "
839
+ "(default: whole period).",
840
+ )
841
+ parser.add_argument(
842
+ "--vg-weight",
843
+ type=float,
844
+ default=0.5,
845
+ help="Convex blend weight in [0,1] on the VG-shortfall term of the "
846
+ "net-load signal; the inflow-demand term gets 1 - vg_weight (default: 0.5).",
847
+ )
848
+ parser.add_argument(
849
+ "--region-groups",
850
+ type=str,
851
+ default=None,
852
+ help="Comma-separated node-group names to demand-weight the net-load "
853
+ "VG term (e.g. 'decomp_AUS,decomp_JAP,decomp_KOR'). Omitted → unweighted "
854
+ "system aggregate (byte-parity default).",
855
+ )
856
+ parser.add_argument(
857
+ "--force-region-scope",
858
+ action="store_true",
859
+ help="Score net load PER region group and greedily force each region's "
860
+ "worst lull under a budget cap, instead of forcing the single "
861
+ "system-coincident worst period. Requires --region-groups.",
862
+ )
863
+ parser.add_argument(
864
+ "--force-region-budget",
865
+ type=int,
866
+ default=None,
867
+ help="Max forced periods under --force-region-scope (default: derived "
868
+ "from n_rp as max(1, n_rp // 2)).",
869
+ )
870
+ parser.add_argument(
871
+ "--solves",
872
+ type=str,
873
+ default=None,
874
+ help="Comma-separated solve names whose period_timeset should point at "
875
+ "the new RP timeset (e.g. 'invest_solve'). Omitted → update every solve "
876
+ "with a period_timeset. A name absent from the DB is an error.",
877
+ )
878
+ parser.add_argument(
879
+ "--alternative-name",
880
+ type=str,
881
+ default=None,
882
+ help="Override the output alternative name (also names the timeset "
883
+ "entity). Give distinct scenarios distinct names so they don't collide "
884
+ "on the alternative or the timeset entity. Omitted → derive from "
885
+ "n_rp/period_length (plus any force suffix).",
886
+ )
887
+ parser.add_argument(
888
+ "--alternative-description",
889
+ type=str,
890
+ default=None,
891
+ help="Override the created alternative's description text. Omitted → "
892
+ "'Representative periods: <timeset>'.",
893
+ )
894
+ parser.add_argument(
895
+ "--netload-clustering",
896
+ action="store_true",
897
+ help="Cluster on the real-MW net-load signal (demand − VRE·avail over "
898
+ "demand-match-sized caps) instead of the per-series normalized "
899
+ "profile/inflow stack. Output name defaults to netload_{n_rp}rp_{PL}h. "
900
+ "Single-shot (iteration-0 caps, no solve feedback).",
901
+ )
902
+ parser.add_argument(
903
+ "--vre-penetration",
904
+ type=float,
905
+ default=1.0,
906
+ help="Energy-share target for the demand-match VRE sizing under "
907
+ "--netload-clustering (1.0 = full-energy match; default 1.0). Ignored "
908
+ "without --netload-clustering.",
909
+ )
910
+
911
+ args = parser.parse_args()
912
+ region_groups = (
913
+ [g.strip() for g in args.region_groups.split(",") if g.strip()]
914
+ if args.region_groups
915
+ else None
916
+ )
917
+ solves = (
918
+ [s.strip() for s in args.solves.split(",") if s.strip()]
919
+ if args.solves
920
+ else None
921
+ )
922
+
923
+ try:
924
+ preprocess_representative_periods(
925
+ db_url=args.db_url,
926
+ scenario_name=args.scenario,
927
+ n_rp=args.n_rp,
928
+ period_length=args.period_length,
929
+ force_peak_load=args.force_peak_load,
930
+ force_highest_net_load=args.force_highest_net_load,
931
+ force_window=args.force_window,
932
+ vg_weight=args.vg_weight,
933
+ region_groups=region_groups,
934
+ force_region_scope=args.force_region_scope,
935
+ force_region_budget=args.force_region_budget,
936
+ solves=solves,
937
+ alternative_name=args.alternative_name,
938
+ alternative_description=args.alternative_description,
939
+ netload_clustering=args.netload_clustering,
940
+ vre_penetration=args.vre_penetration,
941
+ )
942
+ except Exception as e:
943
+ print(f"Error: {e}", file=sys.stderr)
944
+ sys.exit(1)
945
+
946
+
947
+ if __name__ == "__main__":
948
+ main()