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,977 @@
1
+ """Write SheetData objects (from read_self_describing_excel) to a SpineDB.
2
+
3
+ The writer uses a two-pass import strategy:
4
+ 1. First pass — sheets with 1-dimensional entity classes (node, unit, …)
5
+ 2. Second pass — sheets with multi-dimensional entity classes (unit__inputNode, …)
6
+
7
+ This guarantees that base entities exist before relationship entities reference them.
8
+
9
+ Entity classes and parameter definitions are assumed to already exist in the
10
+ target database (initialized from the FlexTool template). This module only
11
+ adds entities, alternatives, entity_alternatives, and parameter_values.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from collections import defaultdict
18
+ from typing import Any
19
+
20
+ from spinedb_api import Array, Asterisk, DatabaseMapping, Map, SpineDBAPIError, to_database
21
+ from spinedb_api.exception import NothingToCommit
22
+
23
+ from flextool.process_inputs.read_self_describing_excel import SheetData
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # The special pseudo-parameter that maps to entity_alternative rows.
28
+ _ENTITY_EXISTENCE = "entity existence"
29
+
30
+ # Index-cell marker the exporter writes for a zero-length Array/Map value.
31
+ # Mirrors excel_writer.EMPTY_COLLECTION_SENTINEL — keep the two in sync.
32
+ _EMPTY_COLLECTION_SENTINEL = "(empty)"
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Helpers
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def _natural_key(v: Any) -> tuple[int, Any]:
41
+ """Sort key that orders integer-like strings numerically.
42
+
43
+ Map/Array index values arrive as strings from the Excel reader. A plain
44
+ string sort orders an integer axis (e.g. ladder tiers) lexically —
45
+ ``"1","10","11",…,"2"`` — silently reordering it versus the authored
46
+ ``"1","2",…,"10","11"``. Returns ``(0, int)`` for integer-like values so
47
+ they sort numerically ahead of, and separately from, genuine strings
48
+ ``(1, str)`` — keeping the order total and deterministic for mixed axes.
49
+ """
50
+ try:
51
+ return (0, int(str(v)))
52
+ except (TypeError, ValueError):
53
+ return (1, str(v))
54
+
55
+
56
+ def _is_multidimensional(sheet: SheetData) -> bool:
57
+ """Return True if the sheet's entity class has more than one dimension."""
58
+ for ec in sheet.metadata.entity_classes:
59
+ if len(ec.dimensions) > 1:
60
+ return True
61
+ return False
62
+
63
+
64
+ def _registry_facet_targets() -> "dict[tuple[str, str], list[tuple[str, tuple[str, ...], frozenset[str]]]]":
65
+ """Return the registry's facet-target map.
66
+
67
+ Keys are ``(entity_class, facet_key)`` — e.g. ``("commodity", "price")`` and
68
+ ``("commodity", "quantity")``. Values are lists of
69
+ ``(canonical_param_name, outer_axis_names, facet_keys)`` tuples covering
70
+ EVERY allowed shape with a facet leaf for that entity class. The
71
+ caller picks the right canonical name by matching the observed outer
72
+ axis vocabulary to ``outer_axis_names`` (e.g. ``("tier",)`` vs
73
+ ``("period", "tier")``).
74
+ """
75
+ try:
76
+ from flextool.engine_polars._param_shapes import (
77
+ LeafKind,
78
+ PARAM_ALLOWED_SHAPES,
79
+ facet_keys,
80
+ shape_to_axes,
81
+ )
82
+ except Exception: # pragma: no cover — registry must be importable
83
+ return {}
84
+
85
+ out: dict[tuple[str, str], list[tuple[str, tuple[str, ...], frozenset[str]]]] = {}
86
+ axis_to_label = {"d": "period", "t": "time", "i": "tier"}
87
+ for (ec, pname), shapes in PARAM_ALLOWED_SHAPES.items():
88
+ for shape in shapes:
89
+ axes = shape_to_axes(shape)
90
+ if axes.leaf is not LeafKind.FACET_PRICE_QUANTITY:
91
+ continue
92
+ outer = tuple(
93
+ axis_to_label.get(getattr(ax, "value", ""), getattr(ax, "value", ""))
94
+ for ax in axes.map_levels
95
+ )
96
+ fkeys = facet_keys(axes.leaf)
97
+ for fk in fkeys:
98
+ out.setdefault((ec, fk), []).append((pname, outer, fkeys))
99
+ return out
100
+
101
+
102
+ def _index_tuple(rec: dict[str, Any]) -> tuple[tuple[str, str], ...]:
103
+ """Build the (axis_name, value) tuple for *rec* in left-to-right order.
104
+
105
+ Combines ``extra_index_values`` (left of rightmost) with the
106
+ ``(index_name, index_value)`` pair from the standard record fields.
107
+ """
108
+ parts: list[tuple[str, str]] = []
109
+ for axis, val in rec.get("extra_index_values") or []:
110
+ if val is not None:
111
+ parts.append((axis, val))
112
+ if rec.get("index_name") and rec.get("index_value") is not None:
113
+ parts.append((rec["index_name"], rec["index_value"]))
114
+ return tuple(parts)
115
+
116
+
117
+ def _combine_facet_records(
118
+ records: list[dict[str, Any]],
119
+ sheet_name: str = "",
120
+ ) -> list[dict[str, Any]]:
121
+ """Combine facet-leaf records emitted by the ladder reader into single
122
+ nested-Map records per canonical parameter.
123
+
124
+ The reader treats the two ``parameter: price`` / ``parameter: quantity``
125
+ Excel columns as separate parameter records. Spine encodes them as a
126
+ single nested Map under a canonical name (e.g.
127
+ ``commodity.price_ladder_cumulative``) — the registry tells us which.
128
+
129
+ Each ladder parameter lives on its own dedicated sheet whose title IS the
130
+ canonical parameter name, so ``sheet_name`` is the authoritative
131
+ disambiguator: when two canonical params share the same outer-axes shape
132
+ (e.g. both ``price_ladder_annual`` and ``price_ladder_cumulative`` allow a
133
+ 2d ``(tier,)`` Map), matching on ``outer_axes`` alone would silently pick
134
+ whichever the registry happens to list first, relabelling the parameter.
135
+
136
+ Records that don't match the registry's facet vocabulary pass through
137
+ unchanged.
138
+ """
139
+ facet_targets = _registry_facet_targets()
140
+ if not facet_targets:
141
+ return records
142
+
143
+ # Bucket key: (entity_class, entity_byname, alternative, outer_index_tuple)
144
+ # where outer_index_tuple is the (axis, value)... part of the row.
145
+ BucketKey = tuple[str, tuple, str, tuple[tuple[str, str], ...]]
146
+ buckets: dict[BucketKey, dict[str, Any]] = {}
147
+ # Track which record-list positions belong to each bucket, so we can
148
+ # drop them in one go.
149
+ bucket_indices: dict[BucketKey, list[int]] = {}
150
+ # Per (entity_class, entity_byname, alt) — track the outer-axes
151
+ # signature so all rows in one group share the same canonical param.
152
+ group_axes: dict[tuple[str, tuple, str], tuple[str, ...]] = {}
153
+
154
+ drop_indices: set[int] = set()
155
+ out: list[dict[str, Any]] = []
156
+
157
+ for i, rec in enumerate(records):
158
+ pname = rec.get("param_name") or ""
159
+ ec = rec.get("entity_class") or ""
160
+ targets = facet_targets.get((ec, pname))
161
+ if not targets:
162
+ continue
163
+ # This record is a facet-leaf candidate. We must still check that
164
+ # the index axes line up with a registered shape.
165
+ idx_pairs = _index_tuple(rec)
166
+ outer_axes = tuple(axis for axis, _ in idx_pairs)
167
+ # Pick the registered (canonical_name, outer_axes, fkeys) entry
168
+ # whose outer_axes matches our observed axes. The facet axes
169
+ # never appear in the index tuple (they're separate columns), so
170
+ # equality is exact. When several canonical params share the same
171
+ # outer-axes shape, prefer the one whose name matches the source
172
+ # sheet (the dedicated ladder sheet is named after its parameter);
173
+ # fall back to the first outer-axes match only when the sheet name
174
+ # is unknown or doesn't correspond to a candidate.
175
+ match: tuple[str, tuple[str, ...], frozenset[str]] | None = None
176
+ for cand in targets:
177
+ if cand[1] == outer_axes and cand[0] == sheet_name:
178
+ match = cand
179
+ break
180
+ if match is None:
181
+ for cand in targets:
182
+ if cand[1] == outer_axes:
183
+ match = cand
184
+ break
185
+ if match is None:
186
+ # No registered shape matches; treat as a normal record.
187
+ continue
188
+
189
+ canonical_name, _axes, fkeys = match
190
+ alt = rec.get("alternative") or ""
191
+ ent = rec.get("entity_byname") or ()
192
+ group_key = (ec, ent, alt)
193
+ prior = group_axes.get(group_key)
194
+ if prior is not None and prior != outer_axes:
195
+ # Same (entity, alt) carries TWO different outer-axes shapes.
196
+ # The registry allows depth-2 and depth-3 to coexist on the
197
+ # same entity (price_ladder_annual), but only one canonical
198
+ # name per (entity, alt) — we refuse to merge silently in
199
+ # that case. Surface a warning and keep the records as-is.
200
+ logger.warning(
201
+ "Facet records for %s.%s under (%s, alt=%s) carry mixed "
202
+ "index axes %s vs %s; skipping facet combination.",
203
+ ec, canonical_name, ent, alt, prior, outer_axes,
204
+ )
205
+ continue
206
+ group_axes[group_key] = outer_axes
207
+
208
+ bkey: BucketKey = (ec, ent, alt, idx_pairs)
209
+ bucket = buckets.get(bkey)
210
+ if bucket is None:
211
+ bucket = {
212
+ "canonical_name": canonical_name,
213
+ "facet_keys": fkeys,
214
+ "facet_values": {},
215
+ "template": rec,
216
+ }
217
+ buckets[bkey] = bucket
218
+ bucket_indices[bkey] = []
219
+ if pname in fkeys:
220
+ bucket["facet_values"][pname] = rec.get("value")
221
+ bucket_indices[bkey].append(i)
222
+ drop_indices.add(i)
223
+
224
+ if not buckets:
225
+ return records
226
+
227
+ # Group buckets by (entity_class, entity_byname, alt, canonical_name)
228
+ # to build one Map per canonical record.
229
+ GroupKey = tuple[str, tuple, str, str]
230
+ grouped: dict[GroupKey, list[tuple[tuple[tuple[str, str], ...], dict[str, Any]]]] = (
231
+ defaultdict(list)
232
+ )
233
+ for bkey, bucket in buckets.items():
234
+ ec, ent, alt, idx_pairs = bkey
235
+ gkey = (ec, ent, alt, bucket["canonical_name"])
236
+ grouped[gkey].append((idx_pairs, bucket))
237
+
238
+ # Emit one combined record per group.
239
+ template_alt: dict[GroupKey, dict[str, Any]] = {}
240
+ for gkey, entries in grouped.items():
241
+ ec, ent, alt, canonical_name = gkey
242
+ # Determine outer-axes signature (uniform across entries within a
243
+ # group — group_axes guarantees it).
244
+ sample_pairs = entries[0][0]
245
+ outer_axes = tuple(axis for axis, _ in sample_pairs)
246
+
247
+ # Sort entries deterministically by outer index tuple values.
248
+ # Numeric-aware: a 1-based integer tier axis must order 1,2,…,10,11
249
+ # (numeric), not the lexical 1,10,11,…,2 a plain string sort yields —
250
+ # the latter silently reorders the ladder versus its authored form
251
+ # and breaks DB round-trip byte-parity.
252
+ entries.sort(key=lambda e: tuple(_natural_key(v) for _, v in e[0]))
253
+
254
+ # Build the nested Map. Inner-leaf Map: facet -> value (axis=
255
+ # "price" per the DB encoding hack).
256
+ facet_keys_order = list(entries[0][1]["facet_keys"])
257
+ # Canonical ordering: price, quantity
258
+ if set(facet_keys_order) == {"price", "quantity"}:
259
+ facet_keys_order = ["price", "quantity"]
260
+ else:
261
+ facet_keys_order.sort()
262
+
263
+ def _leaf_map(facet_values: dict[str, Any]) -> Map:
264
+ # The innermost Map's axis carries the facet key (price /
265
+ # quantity). The canonical DB omits ``index_name`` for this
266
+ # level — Spine's encoder treats the facet axis as anonymous
267
+ # because the keys themselves are the schema. Leaving
268
+ # ``index_name`` unset keeps the JSON byte-identical to the
269
+ # canonical authoring form.
270
+ idxs = facet_keys_order
271
+ vals = [facet_values.get(k) for k in idxs]
272
+ return Map(indexes=idxs, values=vals)
273
+
274
+ if len(outer_axes) == 1:
275
+ # Depth-2 Map(tier -> facet_map)
276
+ inner_indexes = [pairs[0][1] for pairs, _ in entries]
277
+ inner_values = [_leaf_map(b["facet_values"]) for _, b in entries]
278
+ top_map = Map(
279
+ indexes=inner_indexes,
280
+ values=inner_values,
281
+ index_name=outer_axes[0],
282
+ )
283
+ elif len(outer_axes) == 2:
284
+ # Depth-3 Map(period -> Map(tier -> facet_map)). Group by
285
+ # first axis (period).
286
+ from collections import defaultdict as _dd
287
+ by_period: "dict[str, list[tuple[str, dict[str, Any]]]]" = _dd(list)
288
+ for pairs, bucket in entries:
289
+ p_val = pairs[0][1]
290
+ t_val = pairs[1][1]
291
+ by_period[p_val].append((t_val, bucket["facet_values"]))
292
+ period_indexes = list(by_period.keys()) # insertion order
293
+ period_values = []
294
+ for p in period_indexes:
295
+ inner_entries = by_period[p]
296
+ tier_indexes = [t for t, _ in inner_entries]
297
+ tier_values = [_leaf_map(fv) for _, fv in inner_entries]
298
+ period_values.append(Map(
299
+ indexes=tier_indexes,
300
+ values=tier_values,
301
+ index_name=outer_axes[1],
302
+ ))
303
+ top_map = Map(
304
+ indexes=period_indexes,
305
+ values=period_values,
306
+ index_name=outer_axes[0],
307
+ )
308
+ else:
309
+ # Shouldn't happen for registered facet shapes.
310
+ continue
311
+
312
+ template = entries[0][1]["template"]
313
+ combined = dict(template)
314
+ combined["param_name"] = canonical_name
315
+ combined["value"] = top_map
316
+ combined["index_value"] = None
317
+ combined["index_name"] = None
318
+ combined.pop("extra_index_values", None)
319
+ combined["data_type"] = "map"
320
+ template_alt[gkey] = combined
321
+
322
+ # Final output: original records minus dropped ones, plus combined.
323
+ for i, rec in enumerate(records):
324
+ if i in drop_indices:
325
+ continue
326
+ out.append(rec)
327
+ out.extend(template_alt.values())
328
+ return out
329
+
330
+
331
+ def _build_nested_map(
332
+ entries: list[tuple[tuple[tuple[str, str], ...], Any]],
333
+ ) -> Map:
334
+ """Build a depth-N nested ``Map`` from per-leaf ``(index_pairs, value)``.
335
+
336
+ ``index_pairs`` is the left-to-right ``((axis0, val0), (axis1, val1),
337
+ …)`` tuple for one leaf cell (length N == nesting depth). All entries
338
+ must share the same depth and the same per-level axis names. Returns
339
+ ``Map(val0 -> Map(val1 -> … -> leaf))`` with each level's
340
+ ``index_name`` set from its axis, preserving the first-seen order of
341
+ keys at every level (numeric-aware so an integer axis stays
342
+ ``1,2,…,10`` rather than the lexical ``1,10,…,2``).
343
+
344
+ The leaf scalar values are inserted verbatim — the reader has already
345
+ coerced them to the declared leaf dtype (e.g. ``float``).
346
+ """
347
+ # Recursive grouping on the first axis, preserving insertion order.
348
+ def _recurse(
349
+ rows: list[tuple[tuple[tuple[str, str], ...], Any]],
350
+ ) -> Map:
351
+ axis_name = rows[0][0][0][0]
352
+ # Group rows by this level's value, keeping first-seen order.
353
+ order: list[str] = []
354
+ buckets: dict[str, list[tuple[tuple[tuple[str, str], ...], Any]]] = (
355
+ defaultdict(list)
356
+ )
357
+ for pairs, val in rows:
358
+ key = pairs[0][1]
359
+ if key not in buckets:
360
+ order.append(key)
361
+ # Drop the consumed (this-level) pair for the recursion.
362
+ buckets[key].append((pairs[1:], val))
363
+ # Ordering policy: when EVERY key at this level is integer-like,
364
+ # sort numerically so an integer axis stays 1,2,…,10,11 rather than
365
+ # the lexical 1,10,11,…,2 a string sort yields. Otherwise preserve
366
+ # the first-seen (authoring / sheet-row) order — a genuine string
367
+ # axis (e.g. branch names ``realized``, ``f1``) must NOT be
368
+ # alphabetised, which would silently reorder it versus the source.
369
+ if all(_natural_key(k)[0] == 0 for k in order):
370
+ order.sort(key=_natural_key)
371
+ indexes: list[str] = []
372
+ values: list[Any] = []
373
+ for key in order:
374
+ child_rows = buckets[key]
375
+ indexes.append(key)
376
+ if child_rows and child_rows[0][0]:
377
+ # Deeper levels remain — recurse.
378
+ values.append(_recurse(child_rows))
379
+ else:
380
+ # Leaf level — single value per key.
381
+ values.append(child_rows[0][1])
382
+ return Map(indexes=indexes, values=values, index_name=axis_name)
383
+
384
+ return _recurse(entries)
385
+
386
+
387
+ def _group_multi_index_records(
388
+ records: list[dict[str, Any]],
389
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
390
+ """Split out generic multi-index (nested-Map) records.
391
+
392
+ A record carrying a non-empty ``extra_index_values`` describes ONE leaf
393
+ of a depth-N nested float Map (the stochastic ``_s`` reader and the
394
+ ladder reader both emit these). Facet-leaf ladder records are already
395
+ consumed upstream by :func:`_combine_facet_records`, so anything still
396
+ carrying ``extra_index_values`` here is a GENERIC nested Map that has no
397
+ other reconstruction path.
398
+
399
+ Returns ``(passthrough, nested_map_records)``: ``passthrough`` keeps
400
+ every record WITHOUT ``extra_index_values`` for the existing
401
+ scalar/single-index grouper; ``nested_map_records`` are the rebuilt
402
+ collapsed records (one per entity/param/alt group) with a nested Map
403
+ ``value``.
404
+ """
405
+ passthrough: list[dict[str, Any]] = []
406
+ # group key -> list of (index_pairs, value)
407
+ groups: dict[tuple, list[tuple[tuple[tuple[str, str], ...], Any]]] = (
408
+ defaultdict(list)
409
+ )
410
+ templates: dict[tuple, dict[str, Any]] = {}
411
+
412
+ for rec in records:
413
+ extra = rec.get("extra_index_values")
414
+ if not extra or rec.get("index_value") is None:
415
+ passthrough.append(rec)
416
+ continue
417
+ pairs = _index_tuple(rec) # full left-to-right (axis, value) tuple
418
+ if len(pairs) < 2:
419
+ # Not genuinely multi-level after dropping empties — let the
420
+ # single-index grouper handle it.
421
+ passthrough.append(rec)
422
+ continue
423
+ key = (
424
+ rec["entity_class"],
425
+ rec["entity_byname"],
426
+ rec["param_name"],
427
+ rec["alternative"],
428
+ )
429
+ groups[key].append((pairs, rec["value"]))
430
+ if key not in templates:
431
+ templates[key] = rec
432
+
433
+ nested: list[dict[str, Any]] = []
434
+ for key, entries in groups.items():
435
+ top_map = _build_nested_map(entries)
436
+ rec = dict(templates[key])
437
+ rec["value"] = top_map
438
+ rec["index_value"] = None
439
+ rec["index_name"] = None
440
+ rec.pop("extra_index_values", None)
441
+ rec["data_type"] = "map"
442
+ nested.append(rec)
443
+
444
+ return passthrough, nested
445
+
446
+
447
+ def _group_map_records(
448
+ records: list[dict[str, Any]],
449
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
450
+ """Split records into scalar records and grouped Map records.
451
+
452
+ Scalar records have no ``index_value``. Map records share the same
453
+ (entity_class, entity_byname, param_name, alternative) key and are
454
+ collected into a single Map value per group.
455
+
456
+ Generic multi-index records (carrying ``extra_index_values`` — emitted
457
+ by the stochastic ``_s`` reader) are first peeled off and rebuilt into
458
+ depth-N nested Maps via :func:`_group_multi_index_records`; the
459
+ remaining single-index records flow through the original logic below.
460
+
461
+ Returns:
462
+ (scalar_records, map_records) where each map_record is a dict with
463
+ keys identical to an input record but ``value`` replaced by a
464
+ :class:`spinedb_api.Map` built from all the grouped index entries.
465
+ """
466
+ records, nested_maps = _group_multi_index_records(records)
467
+
468
+ scalars: list[dict[str, Any]] = []
469
+ # key -> list of (index_value, value) in insertion order
470
+ map_groups: dict[tuple, list[tuple[str, Any]]] = defaultdict(list)
471
+ # key -> first record (used as template for the collapsed record)
472
+ map_templates: dict[tuple, dict[str, Any]] = {}
473
+
474
+ for rec in records:
475
+ if rec["param_name"] == _ENTITY_EXISTENCE:
476
+ scalars.append(rec)
477
+ continue
478
+
479
+ if rec.get("index_value") is not None:
480
+ key = (
481
+ rec["entity_class"],
482
+ rec["entity_byname"],
483
+ rec["param_name"],
484
+ rec["alternative"],
485
+ )
486
+ map_groups[key].append((rec["index_value"], rec["value"]))
487
+ if key not in map_templates:
488
+ map_templates[key] = rec
489
+ else:
490
+ scalars.append(rec)
491
+
492
+ # Build collapsed Map records
493
+ collapsed: list[dict[str, Any]] = []
494
+ for key, entries in map_groups.items():
495
+ indexes = [e[0] for e in entries]
496
+ values = [e[1] for e in entries]
497
+ index_name = map_templates[key].get("index_name") or "index"
498
+ rec = dict(map_templates[key])
499
+ # When the source sheet's data type marks this parameter as an
500
+ # array (e.g. ``"string (array)"`` / ``"boolean (array)"``), the
501
+ # cells are array-expanded rows where the index cell holds the
502
+ # array slot number OR the period token. Reconstruct an Array
503
+ # so the round-trip preserves the SpineDB ``"array"`` type — a
504
+ # hard requirement for ``model.solves``, ``solve.realized_periods``
505
+ # and friends, which downstream code (engine_polars._solve_config
506
+ # ``params_to_dict``) only treats as ordered sequences when the
507
+ # parsed value is an ``Array`` instance. Index_name is preserved
508
+ # from the source so the engine's axis labels stay intact.
509
+ dtype = (rec.get("data_type") or "").lower()
510
+ is_array_dtype = "(array)" in dtype or dtype.endswith(" array") or dtype == "array"
511
+ if indexes and all(i == _EMPTY_COLLECTION_SENTINEL for i in indexes):
512
+ # Sentinel row written by the exporter for a zero-length
513
+ # Array/Map (excel_writer.EMPTY_COLLECTION_SENTINEL): rebuild an
514
+ # empty collection so an explicit empty override (e.g.
515
+ # solve.contains_solves = []) round-trips instead of collapsing
516
+ # into "no value" and inheriting a lower-ranked alternative.
517
+ if is_array_dtype:
518
+ value = Array(values=[])
519
+ else:
520
+ value = Map(indexes=[], values=[])
521
+ elif is_array_dtype:
522
+ value = Array(values=values, index_name=index_name)
523
+ else:
524
+ value = Map(indexes=indexes, values=values, index_name=index_name)
525
+ rec["value"] = value
526
+ rec["index_value"] = None # no longer individual
527
+ collapsed.append(rec)
528
+
529
+ collapsed.extend(nested_maps)
530
+ return scalars, collapsed
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # Core writing logic for one sheet
535
+ # ---------------------------------------------------------------------------
536
+
537
+
538
+ def _write_sheet(
539
+ sheet: SheetData,
540
+ db: DatabaseMapping,
541
+ alternatives_added: set[str],
542
+ entities_added: set[tuple[str, tuple[str, ...]]],
543
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
544
+ param_values_added: set[tuple[str, tuple[str, ...], str, str]],
545
+ ) -> None:
546
+ """Write a single SheetData into *db*.
547
+
548
+ The caller-owned sets track what has already been added so that
549
+ duplicates are silently skipped.
550
+
551
+ ``param_values_added`` keys on
552
+ ``(entity_class, entity_byname, param_name, alternative)`` — the unique
553
+ identity of a Spine parameter_value. A parameter that can be either
554
+ constant or period-indexed (e.g. ``node.existing``) is split across a
555
+ ``*_c`` constant sheet and a ``*_p`` periodic sheet by the exporter;
556
+ older workbooks emitted the scalar value on BOTH. The constant sheet is
557
+ written first (pass-1 workbook order), so first-writer-wins keeps the
558
+ correctly-typed float and silently drops the periodic-sheet duplicate
559
+ instead of surfacing an alarming "already a parameter_value" warning.
560
+ """
561
+ # -- Link sheets (entity relationships only, no parameters) -------------
562
+ if sheet.link_entities:
563
+ for ec in sheet.metadata.entity_classes:
564
+ for byname in sheet.link_entities:
565
+ key = (ec.class_name, byname)
566
+ if key not in entities_added:
567
+ entities_added.add(key)
568
+ try:
569
+ db.add_entity(
570
+ entity_class_name=ec.class_name,
571
+ entity_byname=byname,
572
+ )
573
+ except SpineDBAPIError as exc:
574
+ logger.warning(
575
+ "Could not add link entity %s (%s): %s",
576
+ byname, ec.class_name, exc,
577
+ )
578
+ return
579
+
580
+ if not sheet.records:
581
+ return
582
+
583
+ # -- Alternatives -------------------------------------------------------
584
+ for rec in sheet.records:
585
+ alt = rec.get("alternative")
586
+ if alt and alt not in alternatives_added:
587
+ alternatives_added.add(alt)
588
+ try:
589
+ db.add_alternative(name=alt)
590
+ except SpineDBAPIError as exc:
591
+ logger.warning("Could not add alternative '%s': %s", alt, exc)
592
+
593
+ # -- Entities -----------------------------------------------------------
594
+ for rec in sheet.records:
595
+ ec_name = rec.get("entity_class")
596
+ byname = rec.get("entity_byname")
597
+ if ec_name and byname:
598
+ key = (ec_name, byname)
599
+ if key not in entities_added:
600
+ entities_added.add(key)
601
+ try:
602
+ db.add_entity(
603
+ entity_class_name=ec_name,
604
+ entity_byname=byname,
605
+ )
606
+ except SpineDBAPIError as exc:
607
+ logger.warning(
608
+ "Could not add entity %s (%s): %s",
609
+ byname, ec_name, exc,
610
+ )
611
+
612
+ # -- Combine facet-leaf records (price_ladder_*) ------------------------
613
+ # Reader emits ``price`` / ``quantity`` as separate per-row records;
614
+ # the registry tells us they belong to a canonical Map-valued param
615
+ # (e.g. ``commodity.price_ladder_cumulative``). Combination must
616
+ # happen BEFORE the generic Map grouper so the resulting top-level
617
+ # Map flows through the scalar branch as a single rec with type=map.
618
+ records = _combine_facet_records(sheet.records, sheet.sheet_name)
619
+
620
+ # -- Split records into scalars and maps --------------------------------
621
+ scalars, maps = _group_map_records(records)
622
+
623
+ # -- Entity existence (entity_alternative) ------------------------------
624
+ for rec in scalars:
625
+ if rec["param_name"] != _ENTITY_EXISTENCE:
626
+ continue
627
+ ec_name = rec["entity_class"]
628
+ byname = rec["entity_byname"]
629
+ alt = rec["alternative"]
630
+ active = bool(rec["value"])
631
+ ea_key = (ec_name, byname, alt)
632
+ if ea_key not in entity_alts_added:
633
+ entity_alts_added.add(ea_key)
634
+ try:
635
+ db.add_entity_alternative(
636
+ entity_class_name=ec_name,
637
+ entity_byname=byname,
638
+ alternative_name=alt,
639
+ active=active,
640
+ )
641
+ except SpineDBAPIError as exc:
642
+ logger.warning(
643
+ "Could not add entity_alternative %s %s '%s': %s",
644
+ ec_name, byname, alt, exc,
645
+ )
646
+
647
+ # -- Scalar parameter values --------------------------------------------
648
+ for rec in scalars:
649
+ if rec["param_name"] == _ENTITY_EXISTENCE:
650
+ continue
651
+ if not rec["param_name"]:
652
+ continue # entity-only record — no param to write
653
+ pv_key = (
654
+ rec["entity_class"], rec["entity_byname"],
655
+ rec["param_name"], rec["alternative"],
656
+ )
657
+ if pv_key in param_values_added:
658
+ continue # already written by a sibling sheet (constant wins)
659
+ param_values_added.add(pv_key)
660
+ value, type_ = to_database(rec["value"])
661
+ try:
662
+ db.add_parameter_value(
663
+ entity_class_name=rec["entity_class"],
664
+ entity_byname=rec["entity_byname"],
665
+ parameter_definition_name=rec["param_name"],
666
+ alternative_name=rec["alternative"],
667
+ value=value,
668
+ type=type_,
669
+ )
670
+ except SpineDBAPIError as exc:
671
+ logger.warning(
672
+ "Could not add parameter value %s.%s (%s, alt=%s): %s",
673
+ rec["entity_class"], rec["param_name"],
674
+ rec["entity_byname"], rec["alternative"], exc,
675
+ )
676
+
677
+ # -- Map parameter values -----------------------------------------------
678
+ for rec in maps:
679
+ pv_key = (
680
+ rec["entity_class"], rec["entity_byname"],
681
+ rec["param_name"], rec["alternative"],
682
+ )
683
+ if pv_key in param_values_added:
684
+ continue # already written by a sibling sheet
685
+ param_values_added.add(pv_key)
686
+ value, type_ = to_database(rec["value"])
687
+ try:
688
+ db.add_parameter_value(
689
+ entity_class_name=rec["entity_class"],
690
+ entity_byname=rec["entity_byname"],
691
+ parameter_definition_name=rec["param_name"],
692
+ alternative_name=rec["alternative"],
693
+ value=value,
694
+ type=type_,
695
+ )
696
+ except SpineDBAPIError as exc:
697
+ logger.warning(
698
+ "Could not add map parameter %s.%s (%s, alt=%s): %s",
699
+ rec["entity_class"], rec["param_name"],
700
+ rec["entity_byname"], rec["alternative"], exc,
701
+ )
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # Ensure 0-dimensional entities referenced by multi-dim sheets
706
+ # ---------------------------------------------------------------------------
707
+
708
+
709
+ def _ensure_dimension_entities(
710
+ data_sheets_nd: list[SheetData],
711
+ db: DatabaseMapping,
712
+ entities_added: set[tuple[str, tuple[str, ...]]],
713
+ ) -> None:
714
+ """Create any missing 0-dim entities referenced as dimensions in multi-dim sheets.
715
+
716
+ Entity classes like ``reserve``, ``upDown``, ``model``, ``group``, ``commodity``
717
+ may have entities that only appear as dimension elements in multi-dimensional
718
+ relationship entities (e.g. ``reserve__upDown__unit__node``). They never get
719
+ their own sheet, so they would be missing after pass 1.
720
+
721
+ This function scans all multi-dim SheetData records *and* link_entities and
722
+ adds the missing 0-dim entities before pass 2 creates the relationships.
723
+ """
724
+ for sheet in data_sheets_nd:
725
+ for ec in sheet.metadata.entity_classes:
726
+ if len(ec.dimensions) <= 1:
727
+ continue
728
+
729
+ # From regular records
730
+ for rec in sheet.records:
731
+ if rec.get("entity_class") != ec.class_name:
732
+ continue
733
+ byname = rec.get("entity_byname")
734
+ if not byname or len(byname) != len(ec.dimensions):
735
+ continue
736
+ for dim_name, elem_name in zip(ec.dimensions, byname):
737
+ if not elem_name:
738
+ continue
739
+ key = (dim_name, (elem_name,))
740
+ if key not in entities_added:
741
+ entities_added.add(key)
742
+ try:
743
+ db.add_entity(
744
+ entity_class_name=dim_name,
745
+ entity_byname=(elem_name,),
746
+ )
747
+ except SpineDBAPIError:
748
+ pass # already exists or class doesn't exist
749
+
750
+ # From link_entities
751
+ for byname in sheet.link_entities:
752
+ if len(byname) != len(ec.dimensions):
753
+ continue
754
+ for dim_name, elem_name in zip(ec.dimensions, byname):
755
+ if not elem_name:
756
+ continue
757
+ key = (dim_name, (elem_name,))
758
+ if key not in entities_added:
759
+ entities_added.add(key)
760
+ try:
761
+ db.add_entity(
762
+ entity_class_name=dim_name,
763
+ entity_byname=(elem_name,),
764
+ )
765
+ except SpineDBAPIError:
766
+ pass # already exists or class doesn't exist
767
+
768
+
769
+ # ---------------------------------------------------------------------------
770
+ # Scenario sheet handling
771
+ # ---------------------------------------------------------------------------
772
+
773
+
774
+ def _write_scenarios(
775
+ sheet: SheetData,
776
+ db: DatabaseMapping,
777
+ alternatives_added: set[str],
778
+ ) -> None:
779
+ """Handle a scenario sheet.
780
+
781
+ Scenario sheets have records where entity_class == 'scenario'.
782
+ Record structure (from parse_scenario_sheet):
783
+ entity_class: 'scenario'
784
+ entity_byname: ('scenario_name',)
785
+ param_name: row label (e.g. 'base_alternative', 'alternative_1')
786
+ alternative: the alternative name
787
+ value: rank (int)
788
+ """
789
+ if not sheet.records:
790
+ return
791
+
792
+ # Group records by scenario name → list of (alternative_name, rank)
793
+ scenario_alts: dict[str, list[tuple[str, int]]] = defaultdict(list)
794
+
795
+ for rec in sheet.records:
796
+ scenario_name = rec.get("entity_byname")
797
+ if scenario_name and isinstance(scenario_name, tuple):
798
+ scenario_name = scenario_name[0]
799
+
800
+ if not scenario_name:
801
+ continue
802
+
803
+ # The 'alternative' field holds the actual alternative name
804
+ alt_name = rec.get("alternative")
805
+ value = rec.get("value")
806
+
807
+ if not alt_name or alt_name == _ENTITY_EXISTENCE:
808
+ continue
809
+
810
+ if isinstance(value, (int, float)):
811
+ rank = int(value)
812
+ else:
813
+ rank = 0
814
+
815
+ scenario_alts[scenario_name].append((alt_name, rank))
816
+
817
+ # Write scenarios and their alternatives
818
+ for scenario_name, alt_ranks in scenario_alts.items():
819
+ try:
820
+ db.add_scenario(name=scenario_name)
821
+ except SpineDBAPIError as exc:
822
+ logger.warning("Could not add scenario '%s': %s", scenario_name, exc)
823
+
824
+ # Sort by rank before adding
825
+ alt_ranks.sort(key=lambda x: x[1])
826
+ for rank, (alt_name, _original_rank) in enumerate(alt_ranks):
827
+ # Ensure the alternative exists
828
+ if alt_name not in alternatives_added:
829
+ alternatives_added.add(alt_name)
830
+ try:
831
+ db.add_alternative(name=alt_name)
832
+ except SpineDBAPIError as exc:
833
+ logger.warning(
834
+ "Could not add alternative '%s': %s", alt_name, exc,
835
+ )
836
+ try:
837
+ db.add_scenario_alternative(
838
+ scenario_name=scenario_name,
839
+ alternative_name=alt_name,
840
+ rank=rank,
841
+ )
842
+ except SpineDBAPIError as exc:
843
+ logger.warning(
844
+ "Could not add scenario_alternative %s -> %s (rank %d): %s",
845
+ scenario_name, alt_name, rank, exc,
846
+ )
847
+
848
+
849
+ def _is_scenario_sheet(sheet: SheetData) -> bool:
850
+ """Return True if the sheet looks like a scenario definition sheet."""
851
+ name = sheet.sheet_name.lower()
852
+ if "scenario" in name:
853
+ return True
854
+ # Also check entity class names
855
+ for ec in sheet.metadata.entity_classes:
856
+ if "scenario" in ec.class_name.lower():
857
+ return True
858
+ return False
859
+
860
+
861
+ # ---------------------------------------------------------------------------
862
+ # Purge helper
863
+ # ---------------------------------------------------------------------------
864
+
865
+
866
+ def _purge_database(db: DatabaseMapping, keep_entities: bool = False) -> None:
867
+ """Remove data from the database before import.
868
+
869
+ Args:
870
+ keep_entities: If True, keep entities (useful when importing into a
871
+ fresh template DB where structural entities like upDown should
872
+ be preserved). Alternatives, scenarios, and parameter values
873
+ are always purged.
874
+ """
875
+ try:
876
+ db.remove_parameter_value(id=Asterisk)
877
+ if not keep_entities:
878
+ db.remove_entity(id=Asterisk)
879
+ db.remove_alternative(id=Asterisk)
880
+ db.remove_scenario(id=Asterisk)
881
+ db.commit_session("Purged database before import")
882
+ except NothingToCommit:
883
+ pass
884
+ except SpineDBAPIError as exc:
885
+ raise RuntimeError(f"Failed to purge database: {exc}") from exc
886
+
887
+
888
+ # ---------------------------------------------------------------------------
889
+ # Public API
890
+ # ---------------------------------------------------------------------------
891
+
892
+
893
+ def write_sheet_data_to_db(
894
+ sheet_data_list: list[SheetData],
895
+ db_url: str,
896
+ purge_first: bool = True,
897
+ keep_entities: bool = False,
898
+ ) -> None:
899
+ """Write parsed SheetData objects into a SpineDB.
900
+
901
+ Uses a two-pass strategy so that 1-dimensional entities are created
902
+ before multi-dimensional (relationship) entities that reference them.
903
+
904
+ Args:
905
+ sheet_data_list: Output of :func:`read_self_describing_excel`.
906
+ db_url: SQLAlchemy-style database URL, e.g.
907
+ ``sqlite:///path/to/db.sqlite``.
908
+ purge_first: If True, remove existing data from the database
909
+ before writing (keeps entity classes and parameter definitions).
910
+ keep_entities: If True (and purge_first is True), keep existing
911
+ entities during purge. Useful when importing into a fresh
912
+ template DB that has structural entities (like upDown).
913
+ """
914
+ with DatabaseMapping(db_url, create=False, upgrade=True) as db:
915
+ if purge_first:
916
+ _purge_database(db, keep_entities=keep_entities)
917
+
918
+ # Tracking sets to avoid duplicate add calls
919
+ alternatives_added: set[str] = set()
920
+ entities_added: set[tuple[str, tuple[str, ...]]] = set()
921
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]] = set()
922
+ param_values_added: set[tuple[str, tuple[str, ...], str, str]] = set()
923
+
924
+ # Separate scenario sheets from data sheets
925
+ scenario_sheets: list[SheetData] = []
926
+ data_sheets_1d: list[SheetData] = []
927
+ data_sheets_nd: list[SheetData] = []
928
+
929
+ for sheet in sheet_data_list:
930
+ if _is_scenario_sheet(sheet):
931
+ scenario_sheets.append(sheet)
932
+ elif _is_multidimensional(sheet):
933
+ data_sheets_nd.append(sheet)
934
+ else:
935
+ data_sheets_1d.append(sheet)
936
+
937
+ # Pass 1: 1-dimensional entity classes
938
+ for sheet in data_sheets_1d:
939
+ logger.info(
940
+ "Pass 1 — writing sheet '%s' (%d records, %d links)",
941
+ sheet.sheet_name, len(sheet.records), len(sheet.link_entities),
942
+ )
943
+ _write_sheet(
944
+ sheet, db, alternatives_added, entities_added,
945
+ entity_alts_added, param_values_added,
946
+ )
947
+
948
+ # Pass 1.5: ensure 0-dim entities referenced by multi-dim sheets
949
+ _ensure_dimension_entities(data_sheets_nd, db, entities_added)
950
+
951
+ # Pass 2: multi-dimensional entity classes
952
+ for sheet in data_sheets_nd:
953
+ logger.info(
954
+ "Pass 2 — writing sheet '%s' (%d records, %d links)",
955
+ sheet.sheet_name, len(sheet.records), len(sheet.link_entities),
956
+ )
957
+ _write_sheet(
958
+ sheet, db, alternatives_added, entities_added,
959
+ entity_alts_added, param_values_added,
960
+ )
961
+
962
+ # Pass 3: scenario sheets
963
+ for sheet in scenario_sheets:
964
+ logger.info(
965
+ "Writing scenario sheet '%s' (%d records)",
966
+ sheet.sheet_name, len(sheet.records),
967
+ )
968
+ _write_scenarios(sheet, db, alternatives_added)
969
+
970
+ # Commit everything
971
+ try:
972
+ db.commit_session("Imported data from self-describing Excel")
973
+ logger.info("Successfully committed all imported data")
974
+ except NothingToCommit:
975
+ logger.info("No new data to commit")
976
+ except SpineDBAPIError as exc:
977
+ raise RuntimeError(f"Failed to commit imported data: {exc}") from exc