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,195 @@
1
+ """Append an alternative onto a scenario's alternative stack.
2
+
3
+ A FlexTool scenario is defined by an *ordered* list of alternatives via the
4
+ ``scenario_alternative`` relationship: each link carries a ``rank`` (integer,
5
+ higher = applied later, so a higher-rank alternative overrides the values of
6
+ lower-rank ones). The representative-periods pre-processor writes its results
7
+ into a fresh RP alternative, but that alternative only influences a solve once
8
+ it is part of the scenario's alternative stack.
9
+
10
+ :func:`add_alternative_to_scenario` appends the RP alternative at the TOP of a
11
+ scenario's stack (the highest rank, so it wins over the baseline
12
+ ``period_timeset``), computing the next rank from the scenario's existing max.
13
+ It mirrors the ``DatabaseMapping`` open/commit style of the RP writer
14
+ (``preprocess.py::_write_results_to_db``) and is idempotent — an alternative
15
+ already in the stack is left untouched.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from spinedb_api import DatabaseMapping
21
+
22
+
23
+ def existing_alternative_names(db_url: str) -> set[str]:
24
+ """Return the set of alternative names already present in the database.
25
+
26
+ A read-only helper the GUI uses to de-duplicate a freshly derived RP
27
+ alternative name (append ``_2``, ``_3``, … when the base is taken) so a
28
+ repeated build never silently overwrites an earlier one.
29
+
30
+ Args:
31
+ db_url: Spine database URL (e.g. ``'sqlite:///path.sqlite'``).
32
+
33
+ Returns:
34
+ The names of every alternative in the (unfiltered) database.
35
+ """
36
+ with DatabaseMapping(db_url) as db:
37
+ return {alt["name"] for alt in db.get_alternative_items()}
38
+
39
+
40
+ def dedup_alternative_name(base: str, taken: set[str]) -> str:
41
+ """Return *base*, or the first free ``base_2`` / ``base_3`` / … suffix.
42
+
43
+ Pure helper (no DB access) so the GUI can compute the de-duplicated name
44
+ against a cached name set both for the live CLI preview and for the launch,
45
+ keeping the two in lock-step.
46
+
47
+ Args:
48
+ base: The desired alternative name.
49
+ taken: Names already in use (case-sensitive).
50
+
51
+ Returns:
52
+ *base* if free, else ``f"{base}_{n}"`` for the smallest ``n >= 2`` that
53
+ is not in *taken*.
54
+ """
55
+ if base not in taken:
56
+ return base
57
+ n = 2
58
+ while f"{base}_{n}" in taken:
59
+ n += 1
60
+ return f"{base}_{n}"
61
+
62
+
63
+ def add_alternative_to_scenario(
64
+ db_url: str,
65
+ scenario_name: str,
66
+ alternative_name: str,
67
+ ) -> None:
68
+ """Append *alternative_name* to *scenario_name*'s stack at the top rank.
69
+
70
+ The alternative is added with ``rank = max(existing ranks) + 1`` so it is
71
+ applied last and overrides the alternatives already in the scenario.
72
+
73
+ Idempotent: if *alternative_name* is already in the scenario's stack, the
74
+ function does nothing (no duplicate link, no rank churn, no error).
75
+
76
+ Args:
77
+ db_url: Spine database URL (e.g. ``'sqlite:///path.sqlite'``).
78
+ scenario_name: Name of the scenario whose stack to extend. Must exist.
79
+ alternative_name: Name of the alternative to append. Must exist.
80
+
81
+ Raises:
82
+ ValueError: If the scenario or the alternative does not exist.
83
+ """
84
+ with DatabaseMapping(db_url) as db:
85
+ # ``get_*_item`` returns an empty dict (falsy) when nothing matches.
86
+ if not db.get_scenario_item(name=scenario_name):
87
+ raise ValueError(f"Scenario '{scenario_name}' does not exist in the database.")
88
+ if not db.get_alternative_item(name=alternative_name):
89
+ raise ValueError(
90
+ f"Alternative '{alternative_name}' does not exist in the database."
91
+ )
92
+
93
+ existing = db.get_scenario_alternative_items(scenario_name=scenario_name)
94
+ # Idempotent: already in the stack (at any rank) → leave it untouched.
95
+ if any(sa["alternative_name"] == alternative_name for sa in existing):
96
+ return
97
+
98
+ next_rank = max((sa["rank"] for sa in existing), default=0) + 1
99
+ # ``add_scenario_alternative`` returns the added item and raises
100
+ # ``SpineDBAPIError`` on failure — let that surface rather than swallow.
101
+ db.add_scenario_alternative(
102
+ scenario_name=scenario_name,
103
+ alternative_name=alternative_name,
104
+ rank=next_rank,
105
+ )
106
+
107
+ db.commit_session(
108
+ f"Append alternative '{alternative_name}' to scenario '{scenario_name}'"
109
+ )
110
+
111
+
112
+ def create_scenario_with_alternative(
113
+ db_url: str,
114
+ base_scenario_name: str,
115
+ new_scenario_name: str,
116
+ alternative_name: str,
117
+ ) -> None:
118
+ """Clone *base_scenario_name* into *new_scenario_name* + append *alternative_name*.
119
+
120
+ Creates *new_scenario_name* as a copy of *base_scenario_name*'s ordered
121
+ alternative stack (same alternatives, same ranks) and then appends
122
+ *alternative_name* at ``max(rank) + 1`` so the RP alternative overrides the
123
+ baseline ``period_timeset``. The base scenario is left completely
124
+ untouched, so the original and the representative-period runs can be
125
+ compared side by side.
126
+
127
+ Idempotent: if *new_scenario_name* already exists it is not recreated — the
128
+ function only ensures *alternative_name* is present at the top of its stack
129
+ (mirroring :func:`add_alternative_to_scenario`), so a repeated launch is a
130
+ no-op rather than an error.
131
+
132
+ Args:
133
+ db_url: Spine database URL (e.g. ``'sqlite:///path.sqlite'``).
134
+ base_scenario_name: Scenario to clone. Must exist.
135
+ new_scenario_name: Name of the scenario to create / extend.
136
+ alternative_name: Alternative to append on top. Must exist.
137
+
138
+ Raises:
139
+ ValueError: If the base scenario or the alternative does not exist.
140
+ """
141
+ with DatabaseMapping(db_url) as db:
142
+ if not db.get_scenario_item(name=base_scenario_name):
143
+ raise ValueError(
144
+ f"Scenario '{base_scenario_name}' does not exist in the database."
145
+ )
146
+ if not db.get_alternative_item(name=alternative_name):
147
+ raise ValueError(
148
+ f"Alternative '{alternative_name}' does not exist in the database."
149
+ )
150
+
151
+ # Create the new scenario the first time, cloning the base stack. On a
152
+ # repeat launch the scenario already exists, so only the top-rank append
153
+ # below runs (idempotent).
154
+ changed = False
155
+ if not db.get_scenario_item(name=new_scenario_name):
156
+ db.add_scenario(name=new_scenario_name)
157
+ for sa in db.get_scenario_alternative_items(
158
+ scenario_name=base_scenario_name
159
+ ):
160
+ db.add_scenario_alternative(
161
+ scenario_name=new_scenario_name,
162
+ alternative_name=sa["alternative_name"],
163
+ rank=sa["rank"],
164
+ )
165
+ changed = True
166
+
167
+ existing = db.get_scenario_alternative_items(
168
+ scenario_name=new_scenario_name
169
+ )
170
+ if not any(
171
+ sa["alternative_name"] == alternative_name for sa in existing
172
+ ):
173
+ next_rank = max((sa["rank"] for sa in existing), default=0) + 1
174
+ db.add_scenario_alternative(
175
+ scenario_name=new_scenario_name,
176
+ alternative_name=alternative_name,
177
+ rank=next_rank,
178
+ )
179
+ changed = True
180
+
181
+ # Nothing to persist on a fully idempotent repeat call — committing an
182
+ # unchanged session raises ``NothingToCommit``.
183
+ if changed:
184
+ db.commit_session(
185
+ f"Create scenario '{new_scenario_name}' from "
186
+ f"'{base_scenario_name}' with alternative '{alternative_name}'"
187
+ )
188
+
189
+
190
+ __all__ = [
191
+ "add_alternative_to_scenario",
192
+ "create_scenario_with_alternative",
193
+ "dedup_alternative_name",
194
+ "existing_alternative_names",
195
+ ]
@@ -0,0 +1,124 @@
1
+ """Convex weight fitting and simplex projection.
2
+
3
+ Implements the weight computation from the hull clustering paper:
4
+ for each base period, find the convex combination of representative periods
5
+ that best approximates it (minimum L2 error).
6
+ """
7
+
8
+ import numpy as np
9
+
10
+
11
+ def project_onto_simplex(v: np.ndarray) -> np.ndarray:
12
+ """Project vector v onto the probability simplex {w >= 0, sum(w) = 1}.
13
+
14
+ Uses Condat's algorithm (O(n) average case).
15
+
16
+ Args:
17
+ v: Input vector of length n.
18
+
19
+ Returns:
20
+ Projected vector on the simplex.
21
+ """
22
+ n = len(v)
23
+ if n == 0:
24
+ return v
25
+ if n == 1:
26
+ return np.array([1.0])
27
+
28
+ u = np.sort(v)[::-1] # sort descending
29
+ cumsum = np.cumsum(u)
30
+ k_candidates = u + (1.0 - cumsum) / np.arange(1, n + 1)
31
+ K = np.max(np.where(k_candidates > 0)[0]) + 1 if np.any(k_candidates > 0) else 1
32
+ tau = (cumsum[K - 1] - 1.0) / K
33
+ return np.maximum(v - tau, 0.0)
34
+
35
+
36
+ def fit_convex_weights(
37
+ R: np.ndarray,
38
+ c: np.ndarray,
39
+ max_iter: int = 200,
40
+ tol: float = 1e-8,
41
+ alpha: float | None = None,
42
+ ) -> np.ndarray:
43
+ """Find convex weights w that minimize ||R @ w - c||^2 s.t. w in simplex.
44
+
45
+ Uses projected gradient descent with simplex projection.
46
+
47
+ Args:
48
+ R: Matrix of representative period feature vectors, shape (n_features, n_rp).
49
+ c: Feature vector of the base period to approximate, shape (n_features,).
50
+ max_iter: Maximum PGD iterations.
51
+ tol: Convergence tolerance.
52
+ alpha: Learning rate. If None, computed from Lipschitz constant.
53
+
54
+ Returns:
55
+ Weight vector w of length n_rp, with w >= 0 and sum(w) = 1.
56
+ """
57
+ n_rp = R.shape[1]
58
+ if n_rp == 1:
59
+ return np.array([1.0])
60
+
61
+ # Precompute R^T R and R^T c for gradient computation
62
+ RtR = R.T @ R
63
+ Rtc = R.T @ c
64
+
65
+ # Learning rate from Lipschitz constant of gradient
66
+ if alpha is None:
67
+ L = np.linalg.norm(RtR, ord=2)
68
+ alpha = 1.0 / max(L, 1e-10)
69
+
70
+ # Initial guess via pseudoinverse
71
+ R_pinv = np.linalg.pinv(R)
72
+ w = project_onto_simplex(R_pinv @ c)
73
+
74
+ for _ in range(max_iter):
75
+ # Gradient: R^T (R w - c) = RtR w - Rtc
76
+ grad = RtR @ w - Rtc
77
+ w_prev = w.copy()
78
+ w = project_onto_simplex(w - alpha * grad)
79
+
80
+ if np.max(np.abs(w - w_prev)) < tol:
81
+ break
82
+
83
+ return w
84
+
85
+
86
+ def distance_to_hull(R: np.ndarray, c: np.ndarray, **kwargs) -> tuple[float, np.ndarray]:
87
+ """Compute the L2 distance from point c to the convex hull of columns of R.
88
+
89
+ Args:
90
+ R: Matrix of representative period feature vectors, shape (n_features, n_rp).
91
+ c: Feature vector to measure distance from, shape (n_features,).
92
+
93
+ Returns:
94
+ (distance, weights): the L2 distance and the optimal weight vector.
95
+ """
96
+ w = fit_convex_weights(R, c, **kwargs)
97
+ residual = R @ w - c
98
+ dist = np.linalg.norm(residual)
99
+ return dist, w
100
+
101
+
102
+ def compute_weight_matrix(
103
+ C: np.ndarray, rep_indices: list[int]
104
+ ) -> np.ndarray:
105
+ """Compute the full weight matrix W for all base periods.
106
+
107
+ Args:
108
+ C: Clustering matrix, shape (n_features, n_base_periods).
109
+ rep_indices: Indices of selected representative periods.
110
+
111
+ Returns:
112
+ Weight matrix W of shape (n_base_periods, n_rp),
113
+ where W[d, r] is the weight of representative period r for base period d.
114
+ Each row sums to 1 and all entries >= 0.
115
+ """
116
+ n_base = C.shape[1]
117
+ n_rp = len(rep_indices)
118
+ R = C[:, rep_indices]
119
+ W = np.zeros((n_base, n_rp))
120
+
121
+ for d in range(n_base):
122
+ W[d, :] = fit_convex_weights(R, C[:, d])
123
+
124
+ return W
@@ -0,0 +1,13 @@
1
+ """Cross-scenario analysis module.
2
+
3
+ Navigation:
4
+ - data_models.py : TimeSeriesResults, DispatchMappings — start here to understand data shapes
5
+ - db_reader.py : Load parquet files from scenario folders → TimeSeriesResults
6
+ - dispatch_mappings.py: Load dispatch mapping parquet files → DispatchMappings
7
+ - config_builder.py : Discover dispatch entity/scenario names + assign palette colors
8
+ - dispatch_data.py : Prepare per-scenario dispatch DataFrames for plotting
9
+ - dispatch_plots.py : Render stacked area dispatch plots
10
+ - orchestrator.py : Top-level run() function tying all pieces together
11
+ """
12
+ from flextool.scenario_comparison.db_reader import get_scenario_results
13
+ __all__ = ['get_scenario_results']
@@ -0,0 +1,158 @@
1
+ """Discover dispatch entity / scenario names and assign palette colors.
2
+
3
+ Reads dispatch mappings to enumerate the group / unit / connection / scenario
4
+ names present in a run, and assigns default palette colors. These feed the
5
+ additive seeding of the project ``plot_settings.yaml`` (the durable colors
6
+ file the renderers read); the legacy ``config.yaml`` system has been removed.
7
+ """
8
+
9
+ import matplotlib
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+
13
+ from flextool.scenario_comparison.constants import DEFAULT_SPECIAL_COLORS
14
+ from flextool.scenario_comparison.data_models import DispatchMappings
15
+
16
+
17
+ def discover_dispatch_entities(
18
+ mappings: DispatchMappings,
19
+ scenarios: list[str],
20
+ ) -> dict[str, list[str]]:
21
+ """Discover and classify dispatch entity / scenario names from mappings.
22
+
23
+ Reads the dispatch-mapping fields and classifies each discovered name
24
+ into the entity class the project ``plot_settings.yaml`` expects:
25
+
26
+ * ``nodeGroup`` — the dispatch ``group`` name(s) being plotted (the
27
+ group__node collection that is the plot subject);
28
+ * ``flowGroup`` — processGroup / unitGroup / connectionGroup aggregate
29
+ names (``group_aggregate`` of ``processGroup_Unit_to_group`` /
30
+ ``processGroup_Group_to_unit`` / ``processGroup_Connection``) that
31
+ stack as items in the dispatch plot;
32
+ * ``unit`` — individual (not-aggregated) unit names, taken as the
33
+ bare ``unit`` / ``process`` entity of the unit member and
34
+ ``not_in_aggregate`` unit fields;
35
+ * ``connection`` — individual (not-aggregated) connection names, taken
36
+ as the bare ``process`` / ``connection`` entity of the connection
37
+ member and ``not_in_aggregate`` connection fields;
38
+ * ``scenarios`` — the run's scenario names.
39
+
40
+ The *bare* entity name is recorded (the unit / connection name), matching
41
+ what the dispatch color resolver looks up — never the ``(process, node)``
42
+ composite string. Nodes are deliberately not discovered (node colors are
43
+ dataset-coupled and are not seeded into the portable settings file).
44
+
45
+ Returns a mapping ``{class -> sorted list of names}`` with the keys
46
+ ``nodeGroup``, ``flowGroup``, ``unit``, ``connection`` and ``scenarios``;
47
+ empty lists when a class has no members.
48
+ """
49
+ node_groups: set[str] = set()
50
+ flow_groups: set[str] = set()
51
+ units: set[str] = set()
52
+ connections: set[str] = set()
53
+
54
+ # --- nodeGroups: the dispatch group(s) being plotted (group__node). ---
55
+ dispatch_groups_df = mappings.dispatch_groups
56
+ if dispatch_groups_df is not None and not dispatch_groups_df.empty:
57
+ if 'group' in dispatch_groups_df.columns:
58
+ node_groups.update(str(g) for g in dispatch_groups_df['group'].unique())
59
+
60
+ # --- flowGroups: the processGroup aggregate names (unit/connection
61
+ # aggregates that stack as items in the dispatch plot). ---
62
+ for pg_attr in (
63
+ 'processGroup_Unit_to_group',
64
+ 'processGroup_Group_to_unit',
65
+ 'processGroup_Connection',
66
+ ):
67
+ pg_df = getattr(mappings, pg_attr, None)
68
+ if pg_df is not None and not pg_df.empty and 'group_aggregate' in pg_df.columns:
69
+ flow_groups.update(str(g) for g in pg_df['group_aggregate'].unique())
70
+
71
+ # --- Individual unit names (bare entity, not the composite) ---
72
+ # processGroup_*_members carry the units/connections that DO aggregate but
73
+ # are still useful per-entity colors when shown individually; the
74
+ # not_in_aggregate_* fields carry the units/connections shown un-aggregated.
75
+ for unit_attr in (
76
+ 'processGroup_unit_to_node_members',
77
+ 'processGroup_node_to_unit_members',
78
+ 'not_in_aggregate_unit_to_node',
79
+ 'not_in_aggregate_node_to_unit',
80
+ ):
81
+ u_df = getattr(mappings, unit_attr, None)
82
+ if u_df is None or u_df.empty:
83
+ continue
84
+ col = 'unit' if 'unit' in u_df.columns else (
85
+ 'process' if 'process' in u_df.columns else None
86
+ )
87
+ if col is not None:
88
+ units.update(str(u) for u in u_df[col].unique())
89
+
90
+ # --- Individual connection names (bare entity) ---
91
+ for conn_attr in (
92
+ 'processGroup_connection_to_node_members',
93
+ 'processGroup_node_to_connection_members',
94
+ 'not_in_aggregate_connection_to_node',
95
+ 'not_in_aggregate_node_to_connection',
96
+ ):
97
+ c_df = getattr(mappings, conn_attr, None)
98
+ if c_df is None or c_df.empty:
99
+ continue
100
+ col = 'connection' if 'connection' in c_df.columns else (
101
+ 'process' if 'process' in c_df.columns else None
102
+ )
103
+ if col is not None:
104
+ connections.update(str(c) for c in c_df[col].unique())
105
+
106
+ na_conn_df = mappings.not_in_aggregate_connection
107
+ if na_conn_df is not None and not na_conn_df.empty:
108
+ col = 'connection' if 'connection' in na_conn_df.columns else (
109
+ 'process' if 'process' in na_conn_df.columns else None
110
+ )
111
+ if col is not None:
112
+ connections.update(str(c) for c in na_conn_df[col].unique())
113
+
114
+ # A name discovered as both a group aggregate and an individual entity is
115
+ # a group (the aggregate is the durable, project-portable color).
116
+ all_groups = node_groups | flow_groups
117
+ units -= all_groups
118
+ connections -= all_groups
119
+ # Guard against a name landing in both individual classes.
120
+ connections -= units
121
+
122
+ scenario_names = [str(s) for s in scenarios if s]
123
+
124
+ return {
125
+ 'nodeGroup': sorted(node_groups),
126
+ 'flowGroup': sorted(flow_groups),
127
+ 'unit': sorted(units),
128
+ 'connection': sorted(connections),
129
+ 'scenarios': sorted(dict.fromkeys(scenario_names)),
130
+ }
131
+
132
+
133
+ def assign_palette_colors(
134
+ names: list[str],
135
+ start_index: int = 0,
136
+ use_tab10: bool = False,
137
+ ) -> dict[str, str]:
138
+ """Assign default palette colors to *names*, in order.
139
+
140
+ Special-token names get their fixed ``DEFAULT_SPECIAL_COLORS`` value;
141
+ everything else cycles the tab20 (or tab10 for scenarios) matplotlib
142
+ palette starting at *start_index* so the assigned colors are stable and
143
+ visually sensible.
144
+
145
+ Returns an ordered ``{name -> '#RRGGBB'}`` mapping for *names*.
146
+ """
147
+ palette = plt.cm.tab10(np.linspace(0, 1, 10)) if use_tab10 else \
148
+ plt.cm.tab20(np.linspace(0, 1, 20))
149
+ span = 10 if use_tab10 else 20
150
+ out: dict[str, str] = {}
151
+ idx = start_index
152
+ for name in names:
153
+ if name in DEFAULT_SPECIAL_COLORS:
154
+ out[name] = DEFAULT_SPECIAL_COLORS[name]
155
+ continue
156
+ out[name] = matplotlib.colors.rgb2hex(palette[idx % span])
157
+ idx += 1
158
+ return out
@@ -0,0 +1,20 @@
1
+ """Color and column-name constants for dispatch and summary plots."""
2
+
3
+ # Default color mapping for special columns
4
+ DEFAULT_SPECIAL_COLORS = {
5
+ # Positive special columns (at top of legend/plot)
6
+ 'LossOfLoad': 'crimson',
7
+ 'Discharge': 'aqua',
8
+ 'Import': 'indigo',
9
+ # Negative special columns (at bottom of legend/plot)
10
+ 'Charge': 'lime',
11
+ 'Export': 'purple',
12
+ 'internal_losses': 'darkgray',
13
+ }
14
+
15
+ # Special columns that should be POSITIVE (at top of stacked plot, top of legend)
16
+ POSITIVE_SPECIAL = ['LossOfLoad', 'Discharge', 'Import']
17
+ # Special columns that should be NEGATIVE (at bottom of stacked plot, bottom of legend)
18
+ NEGATIVE_SPECIAL = ['Charge', 'Export', 'internal_losses']
19
+ # Columns plotted as lines, not stacked areas
20
+ LINE_COLUMNS = ['Curtailed', 'Demand']