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,1267 @@
1
+ """Write FlexTool processed results into a SpineDB (the ``spinedb`` output
2
+ write-method).
3
+
4
+ This module dumps the engine's in-memory ``results`` dict of pandas
5
+ DataFrames (built in :mod:`flextool.process_outputs.write_outputs`) into a
6
+ SpineDB that uses the FlexTool **results schema**
7
+ (``flextool/schemas/spinedb_results_schema.json``). Multiple runs coexist
8
+ in one DB, separated by Spine **alternatives** (one alternative per
9
+ scenario; multiple solves coexist inside the ``solve`` Map axis).
10
+
11
+ Schema note: the results schema JSON was generated by trimming the three
12
+ duplicate reserve entity classes (``unit__reserve__upDown__node.1/.2/.3``)
13
+ from the legacy results template — they carry no parameter definitions and
14
+ nothing references them. The trimmed schema has 12 entity classes and 58
15
+ parameter definitions.
16
+
17
+ Key correctness facts (verified against the engine code, 2026-06-03):
18
+
19
+ * The ``results`` dict keys are the RAW engine table names (e.g.
20
+ ``unit_outputNode_dt_ee``), NOT renamed display names.
21
+ * ``import_data`` does NOT auto-create relationship member entities, so the
22
+ writer explicitly emits every member element entity (each ``unit``,
23
+ ``node``, ``connection``, ``group``, ``reserve``, ``upDown``, plus the
24
+ literal ``model``-class entities ``"cost"`` and ``"model"``) in addition
25
+ to the relationship entities.
26
+ * Capacity Map keys stay ``existing``/``invested``/``divested``/``total``
27
+ (no ``retired`` rename).
28
+ * Discount-factor params (#33/#34) derive from
29
+ ``par.inflation_factor_investment_yearly`` /
30
+ ``par.inflation_factor_operations_yearly``. ``par`` is threaded in as an
31
+ optional keyword; when absent those two params are skipped.
32
+ * Empty / all-NaN cells emit nothing (no empty Maps, no spurious 0).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import logging
39
+ import math
40
+ import os
41
+ import re
42
+ from typing import Any
43
+
44
+ import pandas as pd
45
+ from spinedb_api import DatabaseMapping, import_data
46
+ from spinedb_api.parameter_value import Map
47
+
48
+ from flextool.process_outputs._output_meta import FACTOR, result_key_summary
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ _SCHEMA_PATH = os.path.join(
53
+ os.path.dirname(os.path.dirname(__file__)),
54
+ "schemas",
55
+ "spinedb_results_schema.json",
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Whitelist: raw engine table name -> spec.
61
+ #
62
+ # spec fields:
63
+ # ec : target entity class name
64
+ # param : target parameter definition name
65
+ # entity_dims : number of leading column levels that form the entity byname
66
+ # (after reordering to schema dim order). 0 means a literal
67
+ # fixed entity (see ``fixed_entity``).
68
+ # fixed_entity: literal byname tuple to use when entity_dims == 0
69
+ # (cost params: "cost" / "model").
70
+ # has_time : True for timestep (_dt) frames -> Map [solve]->period->time;
71
+ # False for period (_d) frames -> Map [solve]->period.
72
+ # category : how to treat the remaining (non-entity) column levels:
73
+ # None -> single value column, no category nesting
74
+ # "header" -> remaining column levels become the OUTERMOST
75
+ # Map keys (matching importer position:"header")
76
+ # reserve_filter: 'unit' or 'connection' -> route rows whose process column
77
+ # is a unit / connection respectively.
78
+ # topology : 'connection_triple' -> column is a single connection name;
79
+ # reconstruct the (connection, node, node) byname from
80
+ # ``s.process_source_sink``.
81
+ # ---------------------------------------------------------------------------
82
+
83
+ SPINEDB_OUTPUT_SPEC: dict[str, dict[str, Any]] = {
84
+ # --- node -------------------------------------------------------------
85
+ "node_dt_ep": dict(ec="node", param="balance_t", entity_dims=1,
86
+ has_time=True, category="header"),
87
+ "node_d_ep": dict(ec="node", param="balance", entity_dims=1,
88
+ has_time=False, category="header"),
89
+ "node_state_dt_e": dict(ec="node", param="state_t", entity_dims=1,
90
+ has_time=True, category=None),
91
+ "node_prices_dt_e": dict(ec="node", param="price_t", entity_dims=1,
92
+ has_time=True, category=None),
93
+ "node_capacity_ed_p": dict(ec="node", param="capacity", entity_dims=1,
94
+ has_time=False, category="header",
95
+ entity_index="node"),
96
+ "dual_invest_effective_node_d_e": dict(ec="node", param="invest_marginal",
97
+ entity_dims=1, has_time=False,
98
+ category=None),
99
+ # --- unit -------------------------------------------------------------
100
+ "unit_online_dt_e": dict(ec="unit", param="online_t", entity_dims=1,
101
+ has_time=True, category=None, entity_filter="unit"),
102
+ "unit_online_average_d_e": dict(ec="unit", param="online_average",
103
+ entity_dims=1, has_time=False,
104
+ category=None, entity_filter="unit"),
105
+ "unit_startup_d_e": dict(ec="unit", param="startup_cumulative",
106
+ entity_dims=1, has_time=False, category=None,
107
+ entity_filter="unit"),
108
+ "unit_capacity_ed_p": dict(ec="unit", param="capacity", entity_dims=1,
109
+ has_time=False, category="header",
110
+ entity_index="unit"),
111
+ "dual_invest_effective_unit_d_e": dict(ec="unit", param="invest_marginal",
112
+ entity_dims=1, has_time=False,
113
+ category=None),
114
+ "process_co2_d_eee": dict(ec="unit", param="CO2_annualized",
115
+ entity_dims=1, has_time=False, category="header",
116
+ co2_type="unit"),
117
+ # --- unit__node -------------------------------------------------------
118
+ "unit_outputNode_dt_ee": dict(ec="unit__node", param="flow_t",
119
+ entity_dims=2, has_time=True, category=None),
120
+ "unit_inputNode_dt_ee": dict(ec="unit__node", param="flow_t",
121
+ entity_dims=2, has_time=True, category=None),
122
+ "unit_outputNode_d_ee": dict(ec="unit__node", param="flow_annualized",
123
+ entity_dims=2, has_time=False, category=None),
124
+ "unit_inputNode_d_ee": dict(ec="unit__node", param="flow_annualized",
125
+ entity_dims=2, has_time=False, category=None),
126
+ "unit_ramp_outputs_dt_ee": dict(ec="unit__node", param="ramp_t",
127
+ entity_dims=2, has_time=True, category=None),
128
+ "unit_ramp_inputs_dt_ee": dict(ec="unit__node", param="ramp_t",
129
+ entity_dims=2, has_time=True, category=None),
130
+ "unit_outputs_cf_d_ee": dict(ec="unit__node", param="cf", entity_dims=2,
131
+ has_time=False, category=None),
132
+ "unit_inputs_cf_d_ee": dict(ec="unit__node", param="cf", entity_dims=2,
133
+ has_time=False, category=None),
134
+ "unit_curtailment_outputNode_dt_ee": dict(ec="unit__node",
135
+ param="curtailment_t",
136
+ entity_dims=2, has_time=True,
137
+ category=None),
138
+ "unit_curtailment_share_outputNode_d_ee": dict(ec="unit__node",
139
+ param="curtailment_share",
140
+ entity_dims=2,
141
+ has_time=False,
142
+ category=None),
143
+ # --- connection__node__node ------------------------------------------
144
+ "connection_dt_eee": dict(ec="connection__node__node", param="flow_t",
145
+ entity_dims=1, has_time=True, category=None,
146
+ topology="connection_triple"),
147
+ "connection_d_eee": dict(ec="connection__node__node",
148
+ param="flow_annualized", entity_dims=1,
149
+ has_time=False, category=None,
150
+ topology="connection_triple"),
151
+ "connection_leftward_dt_eee": dict(ec="connection__node__node",
152
+ param="flow_to_first_node_t",
153
+ entity_dims=1, has_time=True,
154
+ category=None,
155
+ topology="connection_triple"),
156
+ "connection_leftward_d_eee": dict(ec="connection__node__node",
157
+ param="flow_to_first_node_annualized",
158
+ entity_dims=1, has_time=False,
159
+ category=None,
160
+ topology="connection_triple"),
161
+ "connection_rightward_dt_eee": dict(ec="connection__node__node",
162
+ param="flow_to_second_node_t",
163
+ entity_dims=1, has_time=True,
164
+ category=None,
165
+ topology="connection_triple"),
166
+ "connection_rightward_d_eee": dict(ec="connection__node__node",
167
+ param="flow_to_second_node_annualized",
168
+ entity_dims=1, has_time=False,
169
+ category=None,
170
+ topology="connection_triple"),
171
+ "connection_cf_d_e": dict(ec="connection__node__node", param="cf",
172
+ entity_dims=1, has_time=False, category=None,
173
+ topology="connection_triple"),
174
+ # --- connection -------------------------------------------------------
175
+ "connection_capacity_ed_p": dict(ec="connection", param="capacity",
176
+ entity_dims=1, has_time=False,
177
+ category="header",
178
+ entity_index="connection"),
179
+ "dual_invest_effective_connection_d_e": dict(ec="connection",
180
+ param="invest_marginal",
181
+ entity_dims=1, has_time=False,
182
+ category=None),
183
+ # --- per-entity cost break-down (period level, category in columns) ---
184
+ # Entity is an INDEX level (named unit/connection/node) and columns are
185
+ # cost categories — same shape as the capacity tables, so they route
186
+ # through the entity_index serializer.
187
+ "cost_unit_annualized_d_ec": dict(ec="unit", param="cost_annualized",
188
+ entity_dims=1, has_time=False,
189
+ category="header", entity_index="unit"),
190
+ "cost_unit_discounted_d_ec": dict(ec="unit", param="cost_discounted",
191
+ entity_dims=1, has_time=False,
192
+ category="header", entity_index="unit"),
193
+ "cost_connection_annualized_d_ec": dict(ec="connection",
194
+ param="cost_annualized",
195
+ entity_dims=1, has_time=False,
196
+ category="header",
197
+ entity_index="connection"),
198
+ "cost_connection_discounted_d_ec": dict(ec="connection",
199
+ param="cost_discounted",
200
+ entity_dims=1, has_time=False,
201
+ category="header",
202
+ entity_index="connection"),
203
+ "cost_node_annualized_d_ec": dict(ec="node", param="cost_annualized",
204
+ entity_dims=1, has_time=False,
205
+ category="header", entity_index="node"),
206
+ "cost_node_discounted_d_ec": dict(ec="node", param="cost_discounted",
207
+ entity_dims=1, has_time=False,
208
+ category="header", entity_index="node"),
209
+ # --- model (cost) -----------------------------------------------------
210
+ "costs_dt_p": dict(ec="model", param="cost_t", entity_dims=0,
211
+ fixed_entity=("cost",), has_time=True,
212
+ category="header"),
213
+ "annualized_costs_d_p": dict(ec="model", param="cost_annualized",
214
+ entity_dims=0, fixed_entity=("cost",),
215
+ has_time=False, category="header"),
216
+ "costs_discounted_d_p": dict(ec="model", param="cost_discounted_solve",
217
+ entity_dims=0, fixed_entity=("model",),
218
+ has_time=False, category="header",
219
+ collapse_solve=True),
220
+ "costs_discounted_p_": dict(ec="model", param="cost_discounted_total",
221
+ entity_dims=0, fixed_entity=("model",),
222
+ has_time=False, category="header",
223
+ series_category=True),
224
+ # --- group ------------------------------------------------------------
225
+ "nodeGroup_flows_dt_g": dict(ec="group", param="flow_t", entity_dims=1,
226
+ has_time=True, category=None),
227
+ "nodeGroup_flows_d_g": dict(ec="group", param="flow_annualized",
228
+ entity_dims=1, has_time=False, category=None),
229
+ "nodeGroup_flows_dt_gpe": dict(ec="group", param="sum_flow_t",
230
+ entity_dims=1, has_time=True,
231
+ category="header"),
232
+ "nodeGroup_flows_d_gpe": dict(ec="group", param="sum_flow_annualized",
233
+ entity_dims=1, has_time=False,
234
+ category="header"),
235
+ "nodeGroup_gd_p": dict(ec="group", param="indicator", entity_dims=1,
236
+ has_time=False, category="header",
237
+ entity_index="group"),
238
+ "nodeGroup_VRE_share_dt_g": dict(ec="group", param="VRE_share_t",
239
+ entity_dims=1, has_time=True,
240
+ category=None),
241
+ "CO2_d_g": dict(ec="group", param="CO2_annualized", entity_dims=1,
242
+ has_time=False, category=None),
243
+ "nodeGroup_inertia_dt_g": dict(ec="group", param="inertia_t",
244
+ entity_dims=1, has_time=True,
245
+ category=None),
246
+ "nodeGroup_inertia_largest_flow_dt_g": dict(ec="group",
247
+ param="inertia_largest_flow_t",
248
+ entity_dims=1, has_time=True,
249
+ category=None),
250
+ "nodeGroup_unit_node_inertia_dt_gee": dict(ec="group",
251
+ param="inertia_unit_node_t",
252
+ entity_dims=1, has_time=True,
253
+ category="header"),
254
+ "nodeGroup_slack_capacity_margin_d_g": dict(ec="group",
255
+ param="slack_capacity_margin",
256
+ entity_dims=1, has_time=False,
257
+ category=None),
258
+ "nodeGroup_slack_inertia_dt_g": dict(ec="group", param="slack_inertia_t",
259
+ entity_dims=1, has_time=True,
260
+ category=None),
261
+ "nodeGroup_slack_nonsync_dt_g": dict(ec="group", param="slack_nonsync_t",
262
+ entity_dims=1, has_time=True,
263
+ category=None),
264
+ # --- group__reserve__upDown ------------------------------------------
265
+ "nodeGroup_slack_reserve_dt_eeg": dict(ec="group__reserve__upDown",
266
+ param="slack_reserve_t",
267
+ entity_dims=3, has_time=True,
268
+ category=None),
269
+ # --- unit/connection__reserve__upDown__node --------------------------
270
+ "process_reserve_upDown_node_dt_eppe": dict(
271
+ ec="unit__reserve__upDown__node", param="reservation_t",
272
+ entity_dims=4, has_time=True, category=None, reserve_split=True),
273
+ "process_reserve_average_d_eppe": dict(
274
+ ec="unit__reserve__upDown__node", param="reservation_average",
275
+ entity_dims=4, has_time=False, category=None, reserve_split=True),
276
+ }
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Public API
281
+ # ---------------------------------------------------------------------------
282
+
283
+ def derive_alternative_name(scenario_name: str, solve_name: str) -> str:
284
+ """Return the Spine alternative name for a run.
285
+
286
+ v1: deterministically returns ``scenario_name`` (no timestamp / random,
287
+ honoring cross-process determinism). Multiple solves coexist inside the
288
+ ``solve`` Map axis of each value; multiple runs coexist as distinct
289
+ alternatives. A future ``--results-alternative`` override hooks here.
290
+ """
291
+ return scenario_name
292
+
293
+
294
+ def ensure_results_db(db_url: str) -> None:
295
+ """Create the results DB from the schema JSON if it is absent; no-op if
296
+ it already exists.
297
+
298
+ The schema JSON is fed straight to ``import_data`` (its top-level keys
299
+ ``entity_classes`` / ``parameter_definitions`` / ``alternatives`` are
300
+ valid ``import_data`` kwargs). ``import_data`` of classes/params is
301
+ idempotent, so re-running against an existing DB would be harmless, but
302
+ we short-circuit on an existing sqlite file for speed.
303
+
304
+ Concurrency contract (the Toolbox re-create context launches parallel
305
+ scenario iterations into ONE shared ``results.sqlite``):
306
+
307
+ * **Creation serializes via a sidecar lock.** For ``sqlite:///`` targets
308
+ the create-if-absent is guarded by an exclusive sidecar lock on
309
+ ``<db>.lock`` (``fcntl.flock`` on POSIX, ``msvcrt.locking`` on
310
+ Windows), so exactly ONE process
311
+ ever builds the schema. Inside the lock we re-check for the target and,
312
+ if another process already created it, discard our temp build and NEVER
313
+ ``os.replace`` over it — so a late rename can never clobber a DB another
314
+ process has already begun importing into. The schema is built into a
315
+ unique temp file in the target directory and ``os.replace``-d into place
316
+ (atomic on the same filesystem) only when the target is still absent.
317
+ * **Subsequent per-process IMPORTs append and serialize via the sqlite
318
+ busy-timeout.** Each scenario writes its own alternative through
319
+ ``write_spinedb`` -> ``import_data``; concurrent appends to the shared
320
+ sqlite file are serialized by spinedb_api's connection busy-timeout —
321
+ ``DatabaseMapping.create_engine`` passes ``connect_args={"timeout":
322
+ sqlite_timeout}`` (default 1800 s), the pysqlite busy-timeout, so a
323
+ writer that finds the DB locked WAITS and retries rather than failing
324
+ with ``SQLITE_BUSY``. We therefore do NOT hold the creation lock across
325
+ the import — appends are independent writers and only the cold-start
326
+ create must be single-writer.
327
+
328
+ Non-sqlite URLs (e.g. a shared server) fall back to the direct in-place
329
+ create (no atomic rename / file lock possible across a network DSN).
330
+ """
331
+ if db_url.startswith("sqlite:///"):
332
+ path = db_url[len("sqlite:///"):]
333
+ # A present DB may still lag the current schema (additive params /
334
+ # classes shipped after it was built). Bring it up to date without
335
+ # taking the cold-start lock in the steady state.
336
+ if os.path.exists(path):
337
+ _top_up_schema_if_stale(db_url)
338
+ return
339
+ _ensure_results_db_atomic(db_url, path)
340
+ return
341
+ # Non-sqlite target: cannot atomically rename; create in place.
342
+ _import_schema(db_url)
343
+
344
+
345
+ def _required_schema_sets() -> tuple[set[str], set[tuple[str, str]]]:
346
+ """The full set of entity-class names and ``(entity_class, param)`` pairs
347
+ the current results schema requires, read from the schema JSON."""
348
+ with open(_SCHEMA_PATH, encoding="utf-8") as fh:
349
+ schema = json.load(fh)
350
+ required_classes = {ec[0] for ec in schema["entity_classes"]}
351
+ required_params = {(pd[0], pd[1]) for pd in schema["parameter_definitions"]}
352
+ return required_classes, required_params
353
+
354
+
355
+ def _top_up_schema_if_stale(db_url: str) -> None:
356
+ """Bring a pre-existing sqlite results DB up to the current schema.
357
+
358
+ An additive schema change (a new entity class or parameter definition)
359
+ never reaches a ``results.sqlite`` built by an older FlexTool: the
360
+ cold-start create short-circuits on the existing file, so a later
361
+ ``write_spinedb`` crashes when it references a param the DB lacks. Detect
362
+ the gap with a **lock-free, read-only gate** and, only when the DB is
363
+ stale, import the missing schema keys **under the sidecar lock** so
364
+ concurrent callers converge on ONE top-up (no duplicate rows, no
365
+ UNIQUE-violation).
366
+ """
367
+ required_classes, required_params = _required_schema_sets()
368
+
369
+ # Gate: lock-free, read-only. In the steady state (nothing missing) we
370
+ # never take the lock and never write. The gate mapping MUST be closed
371
+ # before we take the lock: its item cache would otherwise go stale under
372
+ # another process's concurrent commit (critique BLOCKER #1).
373
+ with DatabaseMapping(db_url) as db:
374
+ have_classes = {c["name"] for c in db.get_entity_class_items()}
375
+ have_params = {(p["entity_class_name"], p["name"])
376
+ for p in db.get_parameter_definition_items()}
377
+ missing_classes = required_classes - have_classes
378
+ missing_params = required_params - have_params
379
+ if not missing_classes and not missing_params:
380
+ return
381
+
382
+ # Stale path: serialize the top-up under the sidecar lock.
383
+ path = db_url[len("sqlite:///"):]
384
+ lock_path = path + ".lock"
385
+ with open(lock_path, "a") as lock_fh:
386
+ _lock_exclusive(lock_fh)
387
+ try:
388
+ # FRESH mapping under the lock (do NOT reuse the gate mapping — its
389
+ # cache is stale after another process's commit). Re-read the
390
+ # have-sets: another process may already have topped up.
391
+ db = DatabaseMapping(db_url)
392
+ try:
393
+ have_classes = {c["name"]
394
+ for c in db.get_entity_class_items()}
395
+ have_params = {(p["entity_class_name"], p["name"])
396
+ for p in db.get_parameter_definition_items()}
397
+ still_missing = (required_classes - have_classes) or (
398
+ required_params - have_params)
399
+ if not still_missing:
400
+ return # another process already topped up
401
+ with open(_SCHEMA_PATH, encoding="utf-8") as fh:
402
+ schema = json.load(fh)
403
+ _apply_derived_descriptions(schema)
404
+ # Import ALL schema keys EXCEPT ``alternatives`` (never re-seed
405
+ # 'Base' or touch user run alternatives). Derive the kwargs
406
+ # rather than hardcoding entity_classes/parameter_definitions,
407
+ # so any future value-list / superclass key is carried too.
408
+ # Re-importing existing entity classes merges on NAME
409
+ # harmlessly; a FUTURE *new* entity class carrying a hardcoded
410
+ # id in the schema JSON would need its id stripped here to
411
+ # avoid an id collision on this top-up path.
412
+ count, errors = import_data(
413
+ db, **{k: v for k, v in schema.items()
414
+ if k != "alternatives"})
415
+ if errors:
416
+ raise RuntimeError(
417
+ f"Failed to top up results schema: {len(errors)} "
418
+ f"errors: {errors[:5]}"
419
+ )
420
+ db.commit_session("Top-up results schema")
421
+ logger.debug("Topped up results DB schema (%d items)", count)
422
+ finally:
423
+ db.close()
424
+ finally:
425
+ _unlock(lock_fh)
426
+
427
+
428
+ def _lock_exclusive(lock_fh) -> None:
429
+ """Acquire a blocking exclusive lock on an open file handle.
430
+
431
+ Portable across POSIX (``fcntl.flock``) and Windows (``msvcrt.locking``);
432
+ ``fcntl`` does not exist on Windows, so dispatch on what imports."""
433
+ try:
434
+ import fcntl
435
+ except ImportError:
436
+ import msvcrt
437
+ import time
438
+
439
+ # ``msvcrt.locking`` locks ``nbytes`` from the current file position;
440
+ # lock a single byte at offset 0. Use the non-blocking variant in a
441
+ # retry loop so we block indefinitely rather than giving up after the
442
+ # ~10s LK_LOCK ceiling.
443
+ lock_fh.seek(0)
444
+ while True:
445
+ try:
446
+ msvcrt.locking(lock_fh.fileno(), msvcrt.LK_NBLCK, 1)
447
+ return
448
+ except OSError:
449
+ time.sleep(0.05)
450
+ else:
451
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
452
+
453
+
454
+ def _unlock(lock_fh) -> None:
455
+ """Release a lock taken by :func:`_lock_exclusive` (portable)."""
456
+ try:
457
+ import fcntl
458
+ except ImportError:
459
+ import msvcrt
460
+
461
+ lock_fh.seek(0)
462
+ try:
463
+ msvcrt.locking(lock_fh.fileno(), msvcrt.LK_UNLCK, 1)
464
+ except OSError:
465
+ pass
466
+ else:
467
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
468
+
469
+
470
+ def _replace_with_retry(src: str, dst: str, *, attempts: int = 20,
471
+ delay: float = 0.05) -> None:
472
+ """``os.replace(src, dst)`` retrying transient Windows sharing errors.
473
+
474
+ On Windows a file that was just closed can stay briefly locked, so the
475
+ rename raises ``PermissionError`` (WinError 32). POSIX never hits this,
476
+ so the first attempt always succeeds there."""
477
+ import time
478
+
479
+ for i in range(attempts):
480
+ try:
481
+ os.replace(src, dst)
482
+ return
483
+ except PermissionError:
484
+ if i == attempts - 1:
485
+ raise
486
+ time.sleep(delay)
487
+
488
+
489
+ def _ensure_results_db_atomic(db_url: str, path: str) -> None:
490
+ """Create the schema DB at ``path`` if absent, safe under concurrent
491
+ cold-start callers writing the SAME shared results DB.
492
+
493
+ The create-if-absent is serialized by an exclusive lock on a sidecar
494
+ ``<db>.lock`` file (``fcntl.flock`` on POSIX, ``msvcrt.locking`` on
495
+ Windows): only the winner of the lock builds the schema. Both the cheap
496
+ pre-check (in :func:`ensure_results_db`) and the re-check UNDER the lock
497
+ guard against ``os.replace`` clobbering a target another process has
498
+ already created (and may already be importing into) — the rename happens
499
+ only while the target is still absent."""
500
+ import uuid
501
+
502
+ target_dir = os.path.dirname(path) or "."
503
+ os.makedirs(target_dir, exist_ok=True)
504
+
505
+ lock_path = path + ".lock"
506
+ # The lock file persists (it is the rendez-vous point); only the lock is
507
+ # released. Opened in append mode so concurrent opens never truncate it.
508
+ with open(lock_path, "a") as lock_fh:
509
+ _lock_exclusive(lock_fh)
510
+ try:
511
+ # Re-check UNDER the lock: another process may have created the
512
+ # target between our pre-check and acquiring the lock. If so we
513
+ # must NOT build/rename — clobbering it could corrupt an import
514
+ # already in progress in that process.
515
+ if os.path.exists(path):
516
+ return
517
+ tmp_path = os.path.join(
518
+ target_dir, f".results-{uuid.uuid4().hex}.sqlite.tmp")
519
+ tmp_url = "sqlite:///" + tmp_path
520
+ try:
521
+ _import_schema(tmp_url)
522
+ # Still absent (we hold the lock, so it cannot appear now) —
523
+ # commit the build atomically. Retry the rename briefly: on
524
+ # Windows a just-closed SQLite handle can linger a moment and
525
+ # ``os.replace`` raises ``PermissionError`` (WinError 32) until
526
+ # it is fully released.
527
+ _replace_with_retry(tmp_path, path)
528
+ finally:
529
+ # Discard the temp on any error mid-build.
530
+ if os.path.exists(tmp_path):
531
+ try:
532
+ os.remove(tmp_path)
533
+ except OSError:
534
+ pass
535
+ finally:
536
+ _unlock(lock_fh)
537
+
538
+
539
+ # ---------------------------------------------------------------------------
540
+ # Parameter-definition descriptions: prose from the schema, UNITS from code.
541
+ #
542
+ # The checked-in results schema carries rich, hand-written prose (per-category
543
+ # breakdowns). We keep that prose but make the **unit** authoritative from the
544
+ # single source (``_output_meta``): at DB-create, for every param that maps to
545
+ # a known transform, we replace the description's leading ``[unit]`` token with
546
+ # the derived one and prepend it where missing. Empty descriptions are filled
547
+ # wholesale. Params with no mapped transform (e.g. ``cf``) keep their
548
+ # hand-written description untouched. This makes unit drift structurally
549
+ # impossible — the units are GENERATED into the schema, never compared against
550
+ # it — so the old CI drift ratchet is retired.
551
+ # ---------------------------------------------------------------------------
552
+
553
+ # Leading unit token: ``[MW]``, ``[per unit]``, or the cost tables' ``M[CUR]``.
554
+ _LEADING_UNIT_RE = re.compile(r"^\s*(?:M\s*\[[^\]]*\]|\[[^\]]*\])\s*")
555
+
556
+
557
+ def _derived_param_meta() -> tuple[dict[tuple[str, str], tuple[str, str]],
558
+ dict[str, tuple[str, str]]]:
559
+ """Build derived ``(unit, short_text)`` keyed by ``(ec, param)`` and an
560
+ unambiguous-by-param fallback.
561
+
562
+ The ``(ec, param)`` map drives the exact class; the param-only fallback
563
+ covers classes absent from :data:`SPINEDB_OUTPUT_SPEC` that share a param
564
+ name with a documented one (e.g. the ``connection__reserve__*`` params),
565
+ but only when that param has a single distinct unit across all classes.
566
+ """
567
+ by_ecp: dict[tuple[str, str], tuple[str, str]] = {}
568
+ units_by_param: dict[str, set[str]] = {}
569
+ text_by_param: dict[str, tuple[str, str]] = {}
570
+ for table_name, spec in SPINEDB_OUTPUT_SPEC.items():
571
+ param = spec.get("param")
572
+ summary = result_key_summary(table_name)
573
+ if not param or summary is None:
574
+ continue
575
+ unit, _sem, tooltip = summary
576
+ if unit == "ratio": # result_key_summary's unitless sentinel
577
+ unit = "" # → renders as "[per unit]" via _unit_tag
578
+ by_ecp.setdefault((spec.get("ec"), param), (unit, tooltip))
579
+ units_by_param.setdefault(param, set()).add(unit)
580
+ text_by_param.setdefault(param, (unit, tooltip))
581
+ # Params sourced from ``par`` (not a results table): the two discount
582
+ # factors are dimensionless (FACTOR).
583
+ for p in ("investments discount factor", "operations discount factor"):
584
+ units_by_param.setdefault(p, {FACTOR.unit})
585
+ text_by_param.setdefault(p, (FACTOR.unit, FACTOR.tooltip))
586
+ unambiguous = {p: text_by_param[p]
587
+ for p, us in units_by_param.items() if len(us) == 1}
588
+ return by_ecp, unambiguous
589
+
590
+
591
+ def _unit_tag(unit: str) -> str:
592
+ """The bracketed unit token; unitless quantities render as ``[per unit]``."""
593
+ return f"[{unit}]" if unit else "[per unit]"
594
+
595
+
596
+ def derived_param_unit_map() -> dict[tuple[str, str], str]:
597
+ """``{(entity_class, param): derived_unit}`` for params with a known
598
+ transform — the authoritative units merged into the schema."""
599
+ by_ecp, _unambiguous = _derived_param_meta()
600
+ return {key: unit for key, (unit, _text) in by_ecp.items()}
601
+
602
+
603
+ def _apply_derived_descriptions(schema: dict) -> None:
604
+ """Merge derived units into ``parameter_definition`` descriptions.
605
+
606
+ Mutates ``schema['parameter_definitions']`` in place: an entry is
607
+ ``[entity_class, param, default, value_list, description, type]``. For
608
+ every param with a derived unit, the leading unit token is replaced with
609
+ the derived one (prose preserved); empty descriptions are filled from the
610
+ derived short text. Params without a derived unit are left untouched."""
611
+ by_ecp, unambiguous = _derived_param_meta()
612
+ for entry in schema.get("parameter_definitions", []):
613
+ ec, param, existing = entry[0], entry[1], entry[4]
614
+ derived = by_ecp.get((ec, param)) or unambiguous.get(param)
615
+ if not derived:
616
+ continue
617
+ unit, short_text = derived
618
+ prose = _LEADING_UNIT_RE.sub("", existing) if existing else short_text
619
+ entry[4] = f"{_unit_tag(unit)} {prose}".strip()
620
+
621
+
622
+ def _import_schema(db_url: str) -> None:
623
+ """Load the results schema JSON into the DB at ``db_url`` (created if
624
+ needed).
625
+
626
+ Uses an explicit ``db.close()`` rather than the ``with DatabaseMapping``
627
+ context manager: ``__exit__`` only closes the *session*, leaving the
628
+ engine's connection pool holding the SQLite file open. ``close()``
629
+ disposes the engine, releasing the handle. This matters on Windows,
630
+ which refuses to ``os.replace`` a file with an open handle (POSIX has no
631
+ such restriction)."""
632
+ with open(_SCHEMA_PATH, encoding="utf-8") as fh:
633
+ schema = json.load(fh)
634
+ _apply_derived_descriptions(schema)
635
+ db = DatabaseMapping(db_url, create=True)
636
+ try:
637
+ count, errors = import_data(db, **schema)
638
+ if errors:
639
+ raise RuntimeError(
640
+ f"Failed to initialize results schema: {len(errors)} errors: "
641
+ f"{errors[:5]}"
642
+ )
643
+ db.commit_session("Initialized results schema")
644
+ logger.debug("Initialized results DB schema (%d items)", count)
645
+ finally:
646
+ db.close()
647
+
648
+
649
+ def write_spinedb(results, s, db_url, scenario_name, solve_name,
650
+ *, purge_alternative=True, par=None) -> int:
651
+ """Write the processed ``results`` into the SpineDB at ``db_url``.
652
+
653
+ Parameters
654
+ ----------
655
+ results : dict[str, pandas.DataFrame]
656
+ Processed results keyed by RAW engine table name.
657
+ s : object
658
+ The engine ``s`` (sets) namespace. Used for solve reconstruction
659
+ (``solve_period_time`` / ``solve_period``), process partitioning
660
+ (``process_unit`` / ``process_connection``) and connection topology
661
+ (``process_source_sink``).
662
+ db_url : str
663
+ Target SpineDB URL (e.g. ``sqlite:///.../results.sqlite``).
664
+ scenario_name, solve_name : str
665
+ Run identifiers. ``scenario_name`` becomes the alternative name;
666
+ ``solve_name`` is used as the ``solve`` Map key for frames whose
667
+ solve axis cannot be reconstructed per-row.
668
+ purge_alternative : bool, keyword-only, default True
669
+ Delete existing ``parameter_value`` items in the target alternative
670
+ before writing (so a re-run replaces rather than duplicates).
671
+ par : object, keyword-only, default None
672
+ The engine ``par`` (parameters) namespace. Required only for the
673
+ two discount-factor params (#33/#34); when None those are skipped.
674
+
675
+ Returns
676
+ -------
677
+ int
678
+ The ``import_data`` count for the parameter-value write.
679
+ """
680
+ ensure_results_db(db_url)
681
+ alternative_name = derive_alternative_name(scenario_name, solve_name)
682
+
683
+ if purge_alternative:
684
+ _purge_alternative(db_url, alternative_name)
685
+
686
+ entities, parameter_values, entity_alternatives = collect_spinedb_payload(
687
+ results, s, alternative_name, solve_name, par=par,
688
+ )
689
+
690
+ with DatabaseMapping(db_url) as db:
691
+ count, errors = import_data(
692
+ db,
693
+ alternatives=[(alternative_name, f"FlexTool results: {alternative_name}")],
694
+ entities=entities,
695
+ parameter_values=parameter_values,
696
+ entity_alternatives=entity_alternatives,
697
+ )
698
+ if errors:
699
+ raise RuntimeError(
700
+ f"Failed to write results to {db_url}: {len(errors)} errors: "
701
+ f"{errors[:5]}"
702
+ )
703
+ db.commit_session(f"FlexTool results: {alternative_name}")
704
+
705
+ logger.debug(
706
+ "Wrote %d items to SpineDB alternative '%s' (%d entities, %d values)",
707
+ count, alternative_name, len(entities), len(parameter_values),
708
+ )
709
+ return count
710
+
711
+
712
+ # ---------------------------------------------------------------------------
713
+ # Payload collection
714
+ # ---------------------------------------------------------------------------
715
+
716
+ def collect_spinedb_payload(results, s, alternative_name, solve_name,
717
+ *, par=None):
718
+ """Build the ``(entities, parameter_values, entity_alternatives)`` lists
719
+ for one run by iterating :data:`SPINEDB_OUTPUT_SPEC`.
720
+
721
+ Member element entities are collected explicitly (``import_data`` does
722
+ not auto-create them) and deduplicated. Whitelist keys absent from
723
+ ``results`` (or empty / all-NaN) are skipped with a debug log.
724
+ """
725
+ entity_set: set[tuple[str, tuple]] = set()
726
+ parameter_values: list[tuple] = []
727
+
728
+ member_classes = _member_class_map(s)
729
+
730
+ for table_name, spec in SPINEDB_OUTPUT_SPEC.items():
731
+ if table_name not in results:
732
+ logger.debug("spinedb: table '%s' absent from results — skipped",
733
+ table_name)
734
+ continue
735
+ df = results[table_name]
736
+ if df is None:
737
+ continue
738
+ if isinstance(df, pd.Series):
739
+ df = df.to_frame()
740
+ if isinstance(df, pd.DataFrame) and df.empty:
741
+ logger.debug("spinedb: table '%s' empty — skipped", table_name)
742
+ continue
743
+ try:
744
+ pvs = df_to_spine_maps(df, spec, s, solve_name, par=par,
745
+ alternative_name=alternative_name)
746
+ except Exception:
747
+ logger.exception(
748
+ "spinedb: failed to serialize table '%s' — skipped",
749
+ table_name)
750
+ continue
751
+ for ec, byname, value in pvs:
752
+ parameter_values.append(
753
+ (ec, byname, spec["param"], value, alternative_name))
754
+ entity_set.add((ec, byname))
755
+ # explicitly emit member element entities
756
+ for member in _member_entities(ec, byname, member_classes):
757
+ entity_set.add(member)
758
+
759
+ # Discount factors (#33 / #34) derive from par, not from results.
760
+ if par is not None:
761
+ for fam_attr, param_name in (
762
+ ("inflation_factor_investment_yearly", "investments discount factor"),
763
+ ("inflation_factor_operations_yearly", "operations discount factor"),
764
+ ):
765
+ value = _discount_factor_map(par, fam_attr, s, solve_name)
766
+ if value is not None:
767
+ parameter_values.append(
768
+ ("model", ("model",), param_name, value, alternative_name))
769
+ entity_set.add(("model", ("model",)))
770
+ else:
771
+ logger.debug("spinedb: par is None — discount-factor params skipped")
772
+
773
+ entities = sorted(entity_set, key=lambda x: (x[0], x[1]))
774
+ entity_alternatives = [
775
+ (ec, byname, alternative_name, True) for ec, byname in entities
776
+ ]
777
+ return entities, parameter_values, entity_alternatives
778
+
779
+
780
+ def _member_class_map(s):
781
+ """Map each relationship entity class to the element class of each of
782
+ its dimensions, for explicit member-entity emission."""
783
+ return {
784
+ "unit__node": ("unit", "node"),
785
+ "connection__node__node": ("connection", "node", "node"),
786
+ "group__reserve__upDown": ("reserve", "upDown", "group"),
787
+ "unit__reserve__upDown__node": ("unit", "reserve", "upDown", "node"),
788
+ "connection__reserve__upDown__node":
789
+ ("connection", "reserve", "upDown", "node"),
790
+ }
791
+
792
+
793
+ def _member_entities(ec, byname, member_classes):
794
+ """Yield ``(element_class, (element,))`` for every member of a
795
+ relationship byname. 0-dim classes yield nothing extra (the entity
796
+ itself is already in the set)."""
797
+ dims = member_classes.get(ec)
798
+ if dims is None:
799
+ return
800
+ for elem_class, elem in zip(dims, byname):
801
+ yield (elem_class, (elem,))
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # DataFrame -> Spine Map serialization
806
+ # ---------------------------------------------------------------------------
807
+
808
+ def df_to_spine_maps(df, spec, s, solve_name, *, par=None, alternative_name=None):
809
+ """Serialize one engine DataFrame into a list of
810
+ ``(entity_class, byname, value)`` triples per :data:`SPINEDB_OUTPUT_SPEC`.
811
+
812
+ The value is a (possibly nested) :class:`spinedb_api.parameter_value.Map`
813
+ with nesting ``[category ->] [solve ->] period [-> time] -> value`` and
814
+ any category dimension as the OUTERMOST key.
815
+ """
816
+ ec = spec["ec"]
817
+ has_time = spec["has_time"]
818
+
819
+ # Special period-less aggregated frames.
820
+ if spec.get("series_category"):
821
+ return _serialize_total_series(df, spec)
822
+
823
+ # Entity-in-index frames (capacity, indicator): the entity is an index
824
+ # level, columns are categories.
825
+ if spec.get("entity_index"):
826
+ return _serialize_entity_index(df, spec, s, solve_name)
827
+
828
+ df = _ensure_solve_level(df, s, has_time)
829
+
830
+ # Reduce the column MultiIndex / Index to a uniform tuple form and
831
+ # split into (entity-part, category-part). Each yield is
832
+ # ``(byname, cat_key, series, row_ec)`` where ``row_ec`` overrides the
833
+ # spec entity class for that row (reserve-split routing) or is None.
834
+ rows = _iter_columns(df, spec, s)
835
+
836
+ out: list[tuple] = []
837
+ for byname, cat_key, series, row_ec in rows:
838
+ if byname is None:
839
+ continue
840
+ value = _series_to_map(series, has_time, cat_key,
841
+ collapse_solve=spec.get("collapse_solve", False),
842
+ solve_name=solve_name)
843
+ if value is None:
844
+ continue
845
+ out.append((row_ec or ec, byname, cat_key, value))
846
+
847
+ return _assemble_entity_values(out, spec)
848
+
849
+
850
+ def _assemble_entity_values(rows, spec):
851
+ """Combine per-(entity, category) Maps into one value per entity.
852
+
853
+ When ``category == "header"`` the category dimension is the outermost
854
+ Map key; otherwise each entity has a single value column.
855
+ """
856
+ use_category = spec.get("category") == "header"
857
+ by_entity: dict[tuple, list[tuple]] = {}
858
+ order: list[tuple] = []
859
+ for row_ec, byname, cat_key, value in rows:
860
+ key = (row_ec, byname)
861
+ if key not in by_entity:
862
+ by_entity[key] = []
863
+ order.append(key)
864
+ by_entity[key].append((cat_key, value))
865
+
866
+ result: list[tuple] = []
867
+ for key in order:
868
+ ec, byname = key
869
+ items = by_entity[key]
870
+ if use_category:
871
+ keys, vals = [], []
872
+ for cat_key, value in items:
873
+ if value is None:
874
+ continue
875
+ keys.append(_cat_key_str(cat_key))
876
+ vals.append(value)
877
+ if not keys:
878
+ continue
879
+ result.append((ec, byname, Map(keys, vals)))
880
+ else:
881
+ # No category nesting: a single value per entity. If multiple
882
+ # source columns mapped to the same entity (e.g. output + input
883
+ # node frames merged), keep the first non-None.
884
+ value = next((v for _c, v in items if v is not None), None)
885
+ if value is None:
886
+ continue
887
+ result.append((ec, byname, value))
888
+ return result
889
+
890
+
891
+ def _cat_key_str(cat_key):
892
+ """Render a category key (str or tuple) as a single Map index string."""
893
+ if cat_key is None:
894
+ return ""
895
+ if isinstance(cat_key, tuple):
896
+ return "__".join(str(x) for x in cat_key)
897
+ return str(cat_key)
898
+
899
+
900
+ # ---------------------------------------------------------------------------
901
+ # Column iteration / entity byname construction
902
+ # ---------------------------------------------------------------------------
903
+
904
+ def _iter_columns(df, spec, s):
905
+ """Yield ``(byname, category_key, series)`` for each data column.
906
+
907
+ ``byname`` is the entity byname tuple (schema dim order, ``updown``
908
+ normalized to ``upDown``). ``category_key`` is the remaining column
909
+ levels (or None). ``series`` is the index->value Series for that column.
910
+ """
911
+ entity_dims = spec["entity_dims"]
912
+ topology = spec.get("topology")
913
+ reserve_split = spec.get("reserve_split", False)
914
+ co2_type = spec.get("co2_type")
915
+ entity_filter = spec.get("entity_filter")
916
+
917
+ units = set(getattr(s, "process_unit", []))
918
+ connections = set(getattr(s, "process_connection", []))
919
+
920
+ # Fixed-entity (cost) frames: every column is a category.
921
+ if entity_dims == 0:
922
+ fixed = spec["fixed_entity"]
923
+ for col in df.columns:
924
+ yield fixed, col, df[col], None
925
+ return
926
+
927
+ triple_lookup = None
928
+ if topology == "connection_triple":
929
+ triple_lookup = _connection_triple_lookup(s)
930
+
931
+ for col in df.columns:
932
+ col_t = col if isinstance(col, tuple) else (col,)
933
+
934
+ if co2_type is not None:
935
+ # columns: (type, process, commodity, node)
936
+ ctype, process, commodity, node = col_t
937
+ if ctype != co2_type:
938
+ continue
939
+ yield (process,), (commodity, node), df[col], None
940
+ continue
941
+
942
+ if topology == "connection_triple":
943
+ conn = col_t[0]
944
+ triple = triple_lookup.get(conn)
945
+ if triple is None:
946
+ logger.debug("spinedb: no topology for connection '%s' — skipped",
947
+ conn)
948
+ continue
949
+ yield triple, None, df[col], None
950
+ continue
951
+
952
+ if reserve_split:
953
+ # columns: (process, reserve, updown, node)
954
+ process, reserve, updown, node = col_t
955
+ byname = (process, reserve, _norm_updown(updown), node)
956
+ if process in units:
957
+ yield byname, None, df[col], "unit__reserve__upDown__node"
958
+ continue
959
+ if process in connections:
960
+ yield byname, None, df[col], "connection__reserve__upDown__node"
961
+ continue
962
+ logger.debug("spinedb: reserve process '%s' neither unit nor "
963
+ "connection — skipped", process)
964
+ continue
965
+
966
+ if entity_filter == "unit" and col_t[0] not in units:
967
+ continue
968
+ if entity_filter == "connection" and col_t[0] not in connections:
969
+ continue
970
+
971
+ entity_part = col_t[:entity_dims]
972
+ cat_part = col_t[entity_dims:]
973
+ byname = tuple(_norm_updown(x) for x in entity_part)
974
+ cat = cat_part[0] if len(cat_part) == 1 else (cat_part or None)
975
+ if isinstance(cat, tuple) and not cat:
976
+ cat = None
977
+ yield byname, cat, df[col], None
978
+
979
+
980
+ def _norm_updown(value):
981
+ """Normalize the ``updown`` label to schema's ``upDown`` spelling."""
982
+ if value == "updown":
983
+ return "upDown"
984
+ if value == "up":
985
+ return "up"
986
+ if value == "down":
987
+ return "down"
988
+ return value
989
+
990
+
991
+ # ---------------------------------------------------------------------------
992
+ # Series -> Map
993
+ # ---------------------------------------------------------------------------
994
+
995
+ def _series_to_map(series, has_time, cat_key, *, collapse_solve, solve_name):
996
+ """Convert a Series indexed by ``[solve,] period [, time]`` into a
997
+ nested Map ``[solve ->] period [-> time] -> value``.
998
+
999
+ ``collapse_solve`` aggregates the whole frame under a single
1000
+ ``solve_name`` key (cost_discounted_solve — the engine frame lacks a
1001
+ per-row solve axis). Empty / all-NaN cells are dropped.
1002
+ """
1003
+ names = list(series.index.names)
1004
+
1005
+ if collapse_solve:
1006
+ inner = _build_period_map(series, has_time, names)
1007
+ if inner is None:
1008
+ return None
1009
+ return Map([str(solve_name)], [inner])
1010
+
1011
+ if "solve" in names:
1012
+ solve_keys: list[str] = []
1013
+ solve_vals: list = []
1014
+ for solve, sub in series.groupby(level="solve", sort=False):
1015
+ sub = sub.droplevel("solve")
1016
+ inner = _build_period_map(sub, has_time,
1017
+ [n for n in names if n != "solve"])
1018
+ if inner is None:
1019
+ continue
1020
+ solve_keys.append(str(solve))
1021
+ solve_vals.append(inner)
1022
+ if not solve_keys:
1023
+ return None
1024
+ return Map(solve_keys, solve_vals)
1025
+
1026
+ # No solve level (could not be reconstructed) — wrap under solve_name.
1027
+ inner = _build_period_map(series, has_time, names)
1028
+ if inner is None:
1029
+ return None
1030
+ return Map([str(solve_name)], [inner])
1031
+
1032
+
1033
+ def _build_period_map(series, has_time, names):
1034
+ """Build ``period -> [time ->] value`` from a Series whose index is
1035
+ ``period [, time]`` (no solve level)."""
1036
+ if has_time and "time" in names:
1037
+ period_keys: list[str] = []
1038
+ period_vals: list = []
1039
+ for period, sub in series.groupby(level="period", sort=False):
1040
+ sub = sub.droplevel("period") if sub.index.nlevels > 1 else sub
1041
+ t_keys: list[str] = []
1042
+ t_vals: list[float] = []
1043
+ for time_idx, val in sub.items():
1044
+ if _is_empty(val):
1045
+ continue
1046
+ t_keys.append(str(time_idx))
1047
+ t_vals.append(float(val))
1048
+ if not t_keys:
1049
+ continue
1050
+ period_keys.append(str(period))
1051
+ period_vals.append(Map(t_keys, t_vals))
1052
+ if not period_keys:
1053
+ return None
1054
+ return Map(period_keys, period_vals)
1055
+
1056
+ # period -> value
1057
+ p_keys: list[str] = []
1058
+ p_vals: list[float] = []
1059
+ if "period" in names and series.index.nlevels > 1:
1060
+ series = series.groupby(level="period", sort=False).first()
1061
+ for period_idx, val in series.items():
1062
+ if _is_empty(val):
1063
+ continue
1064
+ p_keys.append(str(period_idx))
1065
+ p_vals.append(float(val))
1066
+ if not p_keys:
1067
+ return None
1068
+ return Map(p_keys, p_vals)
1069
+
1070
+
1071
+ def _is_empty(val) -> bool:
1072
+ """True for NaN / None values that must not be emitted."""
1073
+ if val is None:
1074
+ return True
1075
+ try:
1076
+ return bool(math.isnan(val))
1077
+ except (TypeError, ValueError):
1078
+ return False
1079
+
1080
+
1081
+ # ---------------------------------------------------------------------------
1082
+ # Solve reconstruction (mirrors the CSV branch, write_outputs.py:940-978)
1083
+ # ---------------------------------------------------------------------------
1084
+
1085
+ def _ensure_solve_level(df, s, has_time):
1086
+ """Add a ``solve`` index level to ``df`` if it lacks one, mirroring the
1087
+ CSV branch logic. Returns ``df`` unchanged when ``solve`` is already
1088
+ present or no ``period`` level exists."""
1089
+ names = list(df.index.names)
1090
+ if "solve" in names or "period" not in names:
1091
+ return df
1092
+ df = df.copy()
1093
+ if has_time and "time" in names:
1094
+ spt = s.solve_period_time
1095
+ if spt.droplevel("solve").duplicated().any():
1096
+ spt = spt[~spt.droplevel("solve").duplicated(keep="last")]
1097
+ pt_to_solve = dict(
1098
+ zip(spt.droplevel("solve"), spt.get_level_values("solve")))
1099
+ # Key off the named 'period'/'time' levels, not positional index
1100
+ # slots — entity-first indexes (e.g. (unit, period, time)) otherwise
1101
+ # read the wrong levels and miss every lookup.
1102
+ period_level = df.index.get_level_values("period")
1103
+ time_level = df.index.get_level_values("time")
1104
+ solve_vals = [pt_to_solve.get((p, t), "")
1105
+ for p, t in zip(period_level, time_level)]
1106
+ df.index = pd.MultiIndex.from_arrays(
1107
+ [solve_vals] + [df.index.get_level_values(n) for n in df.index.names],
1108
+ names=["solve"] + list(df.index.names),
1109
+ )
1110
+ else:
1111
+ unique_sp = s.solve_period[
1112
+ ~s.solve_period.droplevel("solve").duplicated(keep="last")]
1113
+ period_to_solve = dict(zip(unique_sp.get_level_values("period"),
1114
+ unique_sp.get_level_values("solve")))
1115
+ if df.index.nlevels == 1:
1116
+ solve_vals = df.index.map(lambda p: period_to_solve.get(p, ""))
1117
+ df.index = pd.MultiIndex.from_arrays(
1118
+ [solve_vals, df.index], names=["solve", df.index.name])
1119
+ else:
1120
+ period_level = df.index.get_level_values("period")
1121
+ solve_vals = period_level.map(lambda p: period_to_solve.get(p, ""))
1122
+ df.index = pd.MultiIndex.from_arrays(
1123
+ [solve_vals] + [df.index.get_level_values(n)
1124
+ for n in df.index.names],
1125
+ names=["solve"] + list(df.index.names),
1126
+ )
1127
+ return df
1128
+
1129
+
1130
+ # ---------------------------------------------------------------------------
1131
+ # Connection topology
1132
+ # ---------------------------------------------------------------------------
1133
+
1134
+ def _connection_triple_lookup(s):
1135
+ """Build ``connection -> (connection, node, node)`` from
1136
+ ``s.process_source_sink`` filtered to connections.
1137
+
1138
+ The frames keyed by a bare connection name (``connection_dt_eee`` etc.)
1139
+ need the (source, sink) nodes to form the ``connection__node__node``
1140
+ byname. The first matching ``(process, source, sink)`` row wins."""
1141
+ lookup: dict[str, tuple] = {}
1142
+ pss = getattr(s, "process_source_sink", None)
1143
+ connections = set(getattr(s, "process_connection", []))
1144
+ if pss is None:
1145
+ return lookup
1146
+ for process, source, sink in pss:
1147
+ if process not in connections:
1148
+ continue
1149
+ if process not in lookup:
1150
+ lookup[process] = (process, source, sink)
1151
+ return lookup
1152
+
1153
+
1154
+ # ---------------------------------------------------------------------------
1155
+ # Cost helpers
1156
+ # ---------------------------------------------------------------------------
1157
+
1158
+ def _serialize_total_series(series, spec):
1159
+ """Serialize ``costs_discounted_p_`` (a per-category Series, no period
1160
+ axis) into one ``Map(category -> value)`` for entity ``("model",)``."""
1161
+ if isinstance(series, pd.DataFrame):
1162
+ # to_frame() of a Series yields a 1-column frame; squeeze it back.
1163
+ if series.shape[1] == 1:
1164
+ series = series.iloc[:, 0]
1165
+ else:
1166
+ return []
1167
+ keys, vals = [], []
1168
+ for cat, val in series.items():
1169
+ if _is_empty(val):
1170
+ continue
1171
+ keys.append(_cat_key_str(cat))
1172
+ vals.append(float(val))
1173
+ if not keys:
1174
+ return []
1175
+ return [(spec["ec"], spec["fixed_entity"], Map(keys, vals))]
1176
+
1177
+
1178
+ def _serialize_entity_index(df, spec, s, solve_name):
1179
+ """Serialize an entity-in-index frame (capacity / indicator): index is
1180
+ ``(entity[, period[, time]])`` and columns are categories.
1181
+
1182
+ Emits ``(ec, (entity,), Map(category -> [solve ->] period [-> time]))``
1183
+ per entity, dropping all-empty cells / categories."""
1184
+ ec = spec["ec"]
1185
+ has_time = spec["has_time"]
1186
+ entity_level = spec["entity_index"]
1187
+ if entity_level not in (df.index.names or []):
1188
+ logger.debug("spinedb: entity level '%s' not in index of '%s' — skipped",
1189
+ entity_level, spec["param"])
1190
+ return []
1191
+
1192
+ result: list[tuple] = []
1193
+ for entity, sub in df.groupby(level=entity_level, sort=False):
1194
+ sub = sub.droplevel(entity_level)
1195
+ sub = _ensure_solve_level(sub, s, has_time)
1196
+ cat_keys: list[str] = []
1197
+ cat_vals: list = []
1198
+ for col in sub.columns:
1199
+ value = _series_to_map(sub[col], has_time, None,
1200
+ collapse_solve=False, solve_name=solve_name)
1201
+ if value is None:
1202
+ continue
1203
+ cat_keys.append(_cat_key_str(col))
1204
+ cat_vals.append(value)
1205
+ if not cat_keys:
1206
+ continue
1207
+ result.append((ec, (entity,), Map(cat_keys, cat_vals)))
1208
+ return result
1209
+
1210
+
1211
+ def _discount_factor_map(par, attr, s, solve_name):
1212
+ """Build a ``Map(solve -> period -> value)`` from a per-period Series on
1213
+ ``par`` (the inflation factors). Returns None when unavailable/empty."""
1214
+ series = getattr(par, attr, None)
1215
+ if series is None:
1216
+ return None
1217
+ if isinstance(series, pd.DataFrame):
1218
+ if series.shape[1] != 1:
1219
+ return None
1220
+ series = series.iloc[:, 0]
1221
+ if not isinstance(series, pd.Series) or series.empty:
1222
+ return None
1223
+ # Period -> solve reconstruction, then nest solve -> period -> value.
1224
+ period_to_solve = None
1225
+ sp = getattr(s, "solve_period", None)
1226
+ if sp is not None:
1227
+ unique_sp = sp[~sp.droplevel("solve").duplicated(keep="last")]
1228
+ period_to_solve = dict(zip(unique_sp.get_level_values("period"),
1229
+ unique_sp.get_level_values("solve")))
1230
+ by_solve: dict[str, list] = {}
1231
+ order: list[str] = []
1232
+ for period, val in series.items():
1233
+ if _is_empty(val):
1234
+ continue
1235
+ solve = (period_to_solve.get(period) if period_to_solve else None) \
1236
+ or solve_name
1237
+ solve = str(solve)
1238
+ if solve not in by_solve:
1239
+ by_solve[solve] = ([], [])
1240
+ order.append(solve)
1241
+ by_solve[solve][0].append(str(period))
1242
+ by_solve[solve][1].append(float(val))
1243
+ if not order:
1244
+ return None
1245
+ solve_keys, solve_vals = [], []
1246
+ for solve in order:
1247
+ pk, pv = by_solve[solve]
1248
+ solve_keys.append(solve)
1249
+ solve_vals.append(Map(pk, pv))
1250
+ return Map(solve_keys, solve_vals)
1251
+
1252
+
1253
+ # ---------------------------------------------------------------------------
1254
+ # Alternative purge
1255
+ # ---------------------------------------------------------------------------
1256
+
1257
+ def _purge_alternative(db_url, alternative_name):
1258
+ """Delete existing ``parameter_value`` items in ``alternative_name`` so a
1259
+ re-run replaces rather than duplicates. Entities are shared across
1260
+ alternatives and are not purged."""
1261
+ with DatabaseMapping(db_url) as db:
1262
+ items = db.get_parameter_value_items(alternative_name=alternative_name)
1263
+ if not items:
1264
+ return
1265
+ for item in items:
1266
+ db.remove_item("parameter_value", item["id"])
1267
+ db.commit_session(f"Purge results alternative: {alternative_name}")