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,166 @@
1
+ """Idempotent write of the per-scenario adequacy-calibration alternative.
2
+
3
+ Each calibrator iteration needs the current per-node
4
+ ``energy_margin_adder`` (and its enabling ``energy_margin_method =
5
+ inflow_adder``) applied to the model *without editing the fixture's own
6
+ alternatives*. We do that by writing a dedicated calibration alternative
7
+ named ``<scenario>_adeq_calib`` and appending it to the scenario's
8
+ alternative stack at the TOP rank, so its values WIN over any baseline the
9
+ scenario already sets while leaving that baseline untouched.
10
+
11
+ The write is idempotent by construction: :func:`spinedb_api.import_data`
12
+ defaults to ``on_conflict='merge'`` (an UPDATE in place), so re-writing a
13
+ changed adder updates the single existing row rather than creating a
14
+ duplicate. Re-writing an *identical* state leaves nothing to commit, which
15
+ :meth:`DatabaseMapping.commit_session` signals by raising
16
+ :class:`NothingToCommit` — caught and treated as success.
17
+
18
+ The scenario link is wired SEPARATELY from the parameter import, by adding
19
+ only the single new ``(scenario, alt)`` row at the top rank. We must NOT
20
+ route it through ``import_data(scenario_alternatives=...)``: that path
21
+ re-derives the scenario's ENTIRE ordered stack and re-yields every existing
22
+ link with freshly recomputed *1-based* ranks. On a database whose stack is
23
+ stored with a different rank base (real FlexTool models use *0-based* ranks),
24
+ every existing link's rank then shifts by one and collides on the
25
+ ``(scenario, rank)`` unique key — surfacing as spurious "already a
26
+ scenario_alternative" errors for the untouched baseline alternatives. The
27
+ single-row append below is base-agnostic and idempotent (mirrors
28
+ ``representative_periods.scenario_stack.add_alternative_to_scenario``).
29
+
30
+ This module never solves and never touches the network beyond the target
31
+ SpineDB.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from collections import defaultdict
37
+
38
+ from spinedb_api import DatabaseMapping, import_data
39
+ from spinedb_api.exception import NothingToCommit
40
+ from spinedb_api.parameter_value import Map
41
+
42
+ # Suffix appended to a scenario name to form its calibration alternative.
43
+ _CALIB_ALT_SUFFIX = "_adeq_calib"
44
+
45
+
46
+ def _adder_map(cells: dict[tuple[str, str], float]) -> Map:
47
+ """Build a 2-D ``(period → time → float)`` Map from per-cell adders.
48
+
49
+ *cells* is ``{(period, time): adder}`` (the ``timed`` sizer's output for
50
+ one node). The nested ``Map`` round-trips through ``import_data`` into the
51
+ ``pdt_energy_margin_adder.csv`` spec the emitter reads, placing each cell's
52
+ value at exactly that invest ``(period, time)``. Periods and times are
53
+ emitted in sorted order for a deterministic, idempotent write.
54
+ """
55
+ by_period: dict[str, dict[str, float]] = defaultdict(dict)
56
+ for (period, time), value in cells.items():
57
+ by_period[str(period)][str(time)] = float(value)
58
+ periods = sorted(by_period)
59
+ inner_maps = []
60
+ for period in periods:
61
+ times = sorted(by_period[period])
62
+ inner_maps.append(
63
+ Map(times, [by_period[period][t] for t in times], index_name="time")
64
+ )
65
+ return Map(periods, inner_maps, index_name="period")
66
+
67
+
68
+ def calib_alt_name(scenario: str) -> str:
69
+ """Return the calibration alternative name for *scenario*.
70
+
71
+ A pure naming helper (``f"{scenario}{_CALIB_ALT_SUFFIX}"``) so callers
72
+ and tests agree on the alternative the calibrator writes into without
73
+ duplicating the string literal.
74
+ """
75
+ return f"{scenario}{_CALIB_ALT_SUFFIX}"
76
+
77
+
78
+ def _normalise_url(url: str) -> str:
79
+ """Accept either a bare filesystem path or a full SQLAlchemy URL.
80
+
81
+ A bare path (no ``"://"`` scheme) is promoted to a ``sqlite:///`` URL;
82
+ anything already carrying a scheme is passed through verbatim.
83
+ """
84
+ return url if "://" in url else f"sqlite:///{url}"
85
+
86
+
87
+ def write_calib_alt(
88
+ url: str,
89
+ scenario: str,
90
+ per_node_adder: "dict[str, float | dict[tuple[str, str], float]]",
91
+ ) -> None:
92
+ """Write (or update) the calibration alternative for *scenario*.
93
+
94
+ For every ``node -> adder`` in *per_node_adder*, set both
95
+ ``energy_margin_method = inflow_adder`` and ``energy_margin_adder`` on that
96
+ node under the ``<scenario>_adeq_calib`` alternative, and append that
97
+ alternative to *scenario*'s stack at the top rank (higher rank wins; the
98
+ existing stack is left intact).
99
+
100
+ The adder value is EITHER a scalar float (the ``uniform`` sizer — a
101
+ constant per-timestep margin) OR a ``{(period, time): float}`` map (the
102
+ ``timed`` sizer — per-cell margin), written as a 2-D
103
+ ``period → time → float`` :class:`spinedb_api.parameter_value.Map` that
104
+ the emitter ingests as ``pdt_energy_margin_adder.csv``. Both share the
105
+ idempotent-overwrite path.
106
+
107
+ Idempotent: re-writing a changed adder UPDATEs the single row in place
108
+ (``import_data`` merges on conflict); re-writing an identical state
109
+ commits nothing (:class:`NothingToCommit` is swallowed). An empty
110
+ *per_node_adder* still materialises the alternative and its scenario
111
+ link — so a baseline (k=0) iteration is solved *through* the calibration
112
+ alternative from the start, keeping the alternative stack constant
113
+ across every iteration.
114
+
115
+ Parameters
116
+ ----------
117
+ url:
118
+ Target SpineDB — a bare path (promoted to ``sqlite:///``) or a full
119
+ SQLAlchemy URL.
120
+ scenario:
121
+ The model scenario whose stack the calibration alternative joins.
122
+ per_node_adder:
123
+ ``{node_name: adder_MWh}``. May be empty (baseline iteration).
124
+ """
125
+ alt = calib_alt_name(scenario)
126
+
127
+ pvs: list[tuple] = []
128
+ for node, adder in per_node_adder.items():
129
+ pvs.append(("node", node, "energy_margin_method", "inflow_adder", alt))
130
+ value = _adder_map(adder) if isinstance(adder, dict) else float(adder)
131
+ pvs.append(("node", node, "energy_margin_adder", value, alt))
132
+
133
+ with DatabaseMapping(_normalise_url(url)) as db:
134
+ # 1. Materialise the calibration alternative and its per-node adders.
135
+ # Deliberately NO ``scenario_alternatives`` here — see the module
136
+ # docstring: routing the link through import_data re-ranks the whole
137
+ # stack (1-based) and collides on 0-based real-model stacks.
138
+ _count, errors = import_data(
139
+ db,
140
+ alternatives=[alt],
141
+ parameter_values=pvs,
142
+ )
143
+ assert not errors, errors
144
+ try:
145
+ db.commit_session("adeq_calib iteration")
146
+ except NothingToCommit:
147
+ # Re-writing an identical state changes nothing to persist.
148
+ pass
149
+
150
+ # 2. Append the calibration alternative to the scenario's stack at the
151
+ # TOP rank (max existing rank + 1, so its values WIN over the
152
+ # baseline). Idempotent + base-agnostic: touch ONLY the single new
153
+ # (scenario, alt) row, leaving every existing link's rank untouched.
154
+ existing = db.get_scenario_alternative_items(scenario_name=scenario)
155
+ if not any(sa["alternative_name"] == alt for sa in existing):
156
+ next_rank = max((sa["rank"] for sa in existing), default=0) + 1
157
+ db.add_scenario_alternative(
158
+ scenario_name=scenario, alternative_name=alt, rank=next_rank
159
+ )
160
+ try:
161
+ db.commit_session("adeq_calib scenario link")
162
+ except NothingToCommit:
163
+ pass
164
+
165
+
166
+ __all__ = ["calib_alt_name", "write_calib_alt"]
@@ -0,0 +1,110 @@
1
+ """Regenerate the non-parquet output formats from the final surviving parquet.
2
+
3
+ The calibration loop solves each iteration with ``--write-methods parquet``
4
+ only (fast; the loop reads its signals straight from the parquet), and clears
5
+ the tree between iterations, so when :func:`~flextool.calibrate._loop.run_calibration`
6
+ returns the LAST iteration's full results survive at
7
+ ``<out_root>/output_parquet/<scenario>/``.
8
+
9
+ This module turns that surviving parquet tree into the other regular output
10
+ formats (csv / excel / spinedb / plot) **without re-solving** — it drives the
11
+ engine's own disk-replay path (:func:`flextool.process_outputs.write_outputs.write_outputs`
12
+ with ``read_parquet_dir=True``), the same code the ``flextool-write-outputs``
13
+ CLI and the Toolbox re-plot flow use. So the calibrated model's results are
14
+ available "just like regular results", but produced by re-reading parquet
15
+ rather than paying for another solve.
16
+
17
+ Known limitations of the replay path (inherent to reading back from parquet,
18
+ not to this module):
19
+
20
+ * ``spinedb`` replay cannot recover the two investment/operations
21
+ discount-factor parameters (the writer needs the live ``par`` frame) nor the
22
+ ordering of a two-way connection's (source, sink);
23
+ * ``csv`` replay writes the per-table frames only — it does not regenerate the
24
+ native run's ``summary.csv``.
25
+
26
+ parquet is intentionally excluded from the accepted methods: it is already on
27
+ disk (re-writing it would be a no-op the engine guards against anyway).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from pathlib import Path
33
+
34
+ # The formats this post-loop step can regenerate from parquet. ``parquet`` is
35
+ # excluded on purpose (already present); the rest are the regular output
36
+ # formats a normal run would emit.
37
+ FINAL_WRITE_METHOD_CHOICES = ("csv", "excel", "spinedb", "plot")
38
+
39
+
40
+ def write_final_outputs(
41
+ out_root: Path,
42
+ scenario: str,
43
+ write_methods: "list[str] | tuple[str, ...]",
44
+ *,
45
+ results_db_url: str | None = None,
46
+ ) -> list[str]:
47
+ """Replay the final parquet tree into *write_methods*; return what was written.
48
+
49
+ Reads ``<out_root>/output_parquet/<scenario>/`` (the surviving output of the
50
+ last calibration solve) and emits each requested format alongside it —
51
+ ``output_csv/<scenario>/``, ``output_excel/output_<scenario>.xlsx``,
52
+ ``output_plots/<scenario>/`` and/or a results SpineDB — with **no** solve.
53
+
54
+ Parameters
55
+ ----------
56
+ out_root:
57
+ The calibration ``--output-location`` root: the parent that holds
58
+ ``output_parquet/`` (and gains the sibling ``output_csv/`` … trees).
59
+ scenario:
60
+ Scenario name; also the ``output_parquet`` sub-folder to read back.
61
+ write_methods:
62
+ Which formats to regenerate (a subset of
63
+ :data:`FINAL_WRITE_METHOD_CHOICES`). Empty ⇒ nothing to do.
64
+ results_db_url:
65
+ Target SpineDB URL for the ``spinedb`` method; defaults inside the
66
+ engine to ``<out_root>/results.sqlite`` when omitted.
67
+
68
+ Returns
69
+ -------
70
+ The list of methods actually written (empty when *write_methods* is empty).
71
+
72
+ Raises
73
+ ------
74
+ FileNotFoundError
75
+ If the expected ``output_parquet/<scenario>/`` directory is absent —
76
+ i.e. there is no final solve to replay.
77
+ """
78
+ methods = [m for m in write_methods if m]
79
+ if not methods:
80
+ return []
81
+
82
+ out_root = Path(out_root)
83
+ parquet_dir = out_root / "output_parquet" / scenario
84
+ if not parquet_dir.is_dir():
85
+ raise FileNotFoundError(
86
+ f"no parquet output to replay for scenario '{scenario}': "
87
+ f"expected {parquet_dir}/ (did the final solve write outputs?)"
88
+ )
89
+
90
+ # Deferred import: write_outputs pulls in the plotting/Excel stack, which we
91
+ # do not want to load unless the operator actually asked for final outputs.
92
+ from flextool.process_outputs.write_outputs import write_outputs
93
+
94
+ write_outputs(
95
+ scenario_name=scenario,
96
+ output_location=str(out_root),
97
+ # subdir is the folder name under output_parquet/ — the scenario — so
98
+ # the replay reads exactly the tree the calibration loop left behind.
99
+ subdir=scenario,
100
+ read_parquet_dir=True,
101
+ write_methods=methods,
102
+ # Belt-and-suspenders: keep output_location non-empty even if the engine
103
+ # ever tries to fall back to settings resolution on the replay path.
104
+ fallback_output_location=str(out_root),
105
+ results_db_url=results_db_url,
106
+ )
107
+ return methods
108
+
109
+
110
+ __all__ = ["FINAL_WRITE_METHOD_CHOICES", "write_final_outputs"]
@@ -0,0 +1,151 @@
1
+ """Over-build guard for the adequacy calibrator (C1c) — a PURE decision.
2
+
3
+ The sizer (C1b) keeps bumping ``energy_margin_adder`` on every node that
4
+ still sheds unserved energy. On a node whose shortfall is genuinely
5
+ resource-capped — no more firm capacity, imports, or storage can be built to
6
+ serve it — bumping the margin further just injects demand the solve cannot
7
+ serve, and (worse) concentrating demand on an unresponsive node drives the
8
+ solve toward infeasibility. This module decides, each correction, which
9
+ shedding nodes to KEEP bumping and which to FREEZE and FLAG as
10
+ resource-capped so the operator surfaces them for firm capacity / imports /
11
+ storage instead of more demand margin.
12
+
13
+ The observable that IS present
14
+ ------------------------------
15
+ Earlier revisions gated the freeze on margin-induced CURTAILMENT rising AT
16
+ the shedding node (``C_k > C_0``). That is the WRONG observable for the
17
+ calibrator's real targets: demand nodes never curtail (VRE spill is upstream),
18
+ so ``C_k > C_0`` is permanently FALSE at a demand node and the guard could
19
+ NEVER flag it — while it kept concentrating demand there until the solve went
20
+ infeasible. The signature that IS present at a resource-capped demand node is
21
+ a residual that stays FLAT despite its adder rising: the margin is buying no
22
+ adequacy. So the freeze keys off the residual RESPONSE to the bump, not
23
+ curtailment.
24
+
25
+ The rule, per node ``n``, comparing the just-solved iteration ``k`` against
26
+ the prior iteration ``k-1``:
27
+
28
+ * ``ΔSlack(n) = S_{k-1}(n) − S_k(n)`` — unserved energy REDUCED (MWh) by the
29
+ bump ``n`` received last round.
30
+
31
+ Freeze ``n`` (and add it to the flagged set) when the PRECONDITION and the
32
+ STALL condition both hold:
33
+
34
+ * PRECONDITION — ``S_{k-1}(n) > shed_tol``: the node was ALREADY shedding in
35
+ the prior iteration, so it WAS bumped last round. The freeze rule asks "did
36
+ MY prior bump to this node fail to buy adequacy?", which is only meaningful
37
+ for a node that was actually shedding (and therefore bumped) last round. A
38
+ node with ``S_{k-1}(n) ≤ shed_tol`` was NOT shedding last round (so the sizer
39
+ proposed no bump for it) and only STARTS shedding this round — because margin
40
+ added to OTHER nodes shifted dispatch onto it. Such a genuinely short,
41
+ never-yet-bumped node must get its FIRST bump, never be frozen; without this
42
+ precondition its ``ΔSlack < 0`` (gap grew from zero) would trip the stall
43
+ condition (``ΔSlack < stall_fraction · 0 = 0``) and freeze it forever,
44
+ exactly backwards. ``shed_tol`` is the SAME per-node shedding tolerance the
45
+ sizer uses (:func:`._sizing.sized_increments`), threaded in so the two agree
46
+ on "was this node shedding?".
47
+
48
+ * STALL — ``ΔSlack(n) < stall_fraction · S_{k-1}(n)``: the residual barely
49
+ moved (or grew) in response to last round's bump, i.e. the margin bought
50
+ essentially no adequacy. ``stall_fraction`` is the CLI ``--stall-fraction``
51
+ knob (default :data:`_STALLED_GAP_FRACTION` = 0.05): a HIGHER value freezes a
52
+ stalled node SOONER (demands a larger relative residual drop to keep bumping).
53
+
54
+ The precondition plus the stall condition are required — deliberately
55
+ conservative, biased to trip one iteration LATE rather than early (every node
56
+ gets at least one bump before it can be flagged, and a little idle capacity
57
+ beats leaving real unserved energy on a node margin CAN still help). A
58
+ legitimately resource-capped node still gets frozen: it sheds every round (so
59
+ ``S_{k-1} > shed_tol`` once it has been bumped) and its residual stalls, so the
60
+ condition trips after it has had its bump(s), and it never runs away to
61
+ infeasibility. The guard needs a prior iteration to diff, so it can only act
62
+ from the SECOND correction onward; the loop calls it only when a prior record
63
+ exists. Frozen nodes stay frozen for the rest of the run — the loop persists
64
+ the flagged set and never bumps a flagged node again.
65
+
66
+ Curtailment plays NO part in the freeze decision (the loop still reads it for
67
+ reporting). Everything here is pure arithmetic over plain dicts — no I/O — so
68
+ the rule is unit-testable in isolation, which is the point of C1c.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ # Default fraction of the prior gap below which ΔSlack counts as "stopped
74
+ # moving" (the STALL condition). Exposed on the CLI as ``--stall-fraction``;
75
+ # this constant is the default the loop threads in.
76
+ _STALLED_GAP_FRACTION = 0.05
77
+
78
+ # Default per-node shedding tolerance (MWh) for the freeze PRECONDITION —
79
+ # mirrors the sizer's ``_sizing._SHED_TOL_MWH`` so guard and sizer agree on
80
+ # "was this node shedding last round?". The loop threads the sizer's actual
81
+ # constant in; this default keeps the pure function standalone-testable.
82
+ _DEFAULT_SHED_TOL_MWH = 1e-6
83
+
84
+
85
+ def guard_freeze(
86
+ candidate: dict[str, float],
87
+ *,
88
+ residual: dict[str, float],
89
+ prev_residual: dict[str, float],
90
+ stall_fraction: float = _STALLED_GAP_FRACTION,
91
+ shed_tol: float = _DEFAULT_SHED_TOL_MWH,
92
+ ) -> tuple[dict[str, float], set[str]]:
93
+ """Freeze resource-capped nodes out of *candidate* increments.
94
+
95
+ Applies the freeze rule (module docstring) to each node *candidate*
96
+ proposes to bump, using the current iteration ``k`` residual (*residual*)
97
+ and the prior iteration ``k-1`` residual (*prev_residual*). A node is
98
+ frozen (and flagged) when it was shedding last round
99
+ (``prev_residual[node] > shed_tol``) AND its residual failed to respond to
100
+ that bump (``ΔSlack < stall_fraction · prev_residual[node]``).
101
+ Curtailment is NOT consulted — a demand node never curtails, so keying the
102
+ freeze on curtailment could never flag it (the bug this replaces).
103
+
104
+ Parameters
105
+ ----------
106
+ candidate
107
+ ``{node: increment}`` the sizer proposes for this correction (already
108
+ stripped of persistently-flagged nodes by the caller).
109
+ residual, prev_residual
110
+ This iteration's and the prior iteration's per-node residual unserved
111
+ energy (MWh). A node absent from either contributes ``0.0``.
112
+ stall_fraction
113
+ Fraction of the prior gap below which ΔSlack counts as stalled
114
+ (``config.stall_fraction`` / ``--stall-fraction``). HIGHER freezes a
115
+ stalled node sooner.
116
+ shed_tol
117
+ Per-node shedding tolerance (MWh). A node is eligible for freezing
118
+ only if it was shedding LAST round (``prev_residual[node] > shed_tol``);
119
+ a node at/below it was not bumped last round and must get its first
120
+ bump. Threaded from the sizer so the two agree (default matches
121
+ :data:`._sizing._SHED_TOL_MWH`).
122
+
123
+ Returns
124
+ -------
125
+ tuple[dict[str, float], set[str]]
126
+ ``(kept, newly_flagged)`` — *candidate* with the frozen nodes removed,
127
+ and the set of nodes flagged this round.
128
+ """
129
+ kept: dict[str, float] = {}
130
+ newly_flagged: set[str] = set()
131
+ for node, inc in candidate.items():
132
+ s_k = residual.get(node, 0.0)
133
+ s_prev = prev_residual.get(node, 0.0)
134
+ delta_slack = s_prev - s_k
135
+
136
+ # PRECONDITION: only a node that was shedding last round was bumped
137
+ # last round, so the freeze question is only meaningful for it. A
138
+ # newly-shedding node (s_prev <= shed_tol) must get its first bump.
139
+ cond_was_shedding = s_prev > shed_tol
140
+ # STALL: the residual barely moved despite the bump → margin buys no
141
+ # adequacy at this (resource-capped) node.
142
+ cond_stalled = delta_slack < stall_fraction * s_prev
143
+
144
+ if cond_was_shedding and cond_stalled:
145
+ newly_flagged.add(node)
146
+ else:
147
+ kept[node] = inc
148
+ return kept, newly_flagged
149
+
150
+
151
+ __all__ = ["guard_freeze"]