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,1139 @@
1
+ """:class:`SpineDBBackend` — EAV → in-memory polars materialiser.
2
+
3
+ The Backend opens a :class:`spinedb_api.DatabaseMapping`, applies a
4
+ scenario filter, eagerly fetches the parameter / entity tables (same
5
+ ``fetch_all`` pattern used by :mod:`flextool.engine_polars._db_loader`
6
+ and :mod:`flextool.engine_polars._solve_config`), and exposes per-spec
7
+ materialiser methods that each return a canonical
8
+ :class:`polars.DataFrame`.
9
+
10
+ Step 2.5 scope
11
+ --------------
12
+
13
+ Items 1-4 of the work plan (specs/step_2_5_audit.md §7) absorb the
14
+ three spec-driven flatteners from the legacy ``input_writer`` into
15
+ this Backend:
16
+
17
+ * :meth:`parameter_defaults` (Item 2) — replaces ``write_default_values``
18
+ * :meth:`entities` (Item 3) — replaces ``write_entity``
19
+ * :meth:`parameter_values` (Item 4) — replaces ``write_parameter``
20
+
21
+ Subsequent items port the four input-time derivations (DC power flow,
22
+ process method, commodity ladders) and rewire orchestration to consume
23
+ Backend output via the Provider.
24
+
25
+ Hard rules
26
+ ----------
27
+
28
+ * **No disk writes anywhere in the Backend.** Every method returns a
29
+ polars frame.
30
+ * **No disk read fallbacks.** The Backend reads SpineDB, full stop.
31
+
32
+ Note on :class:`flextool.engine_polars._spinedb_reader.SpineDbReader`
33
+ --------------------------------------------------------------------
34
+
35
+ The pre-existing ``SpineDbReader`` (used by the native cascade LP
36
+ build path) is a sibling abstraction over the same EAV schema, but with a
37
+ different consumer contract — it returns per-(entity_class,
38
+ parameter_name) frames in the canonical-axis schema used by the
39
+ ``_axis_enums``-driven LP build path, not the spec-driven tabular
40
+ ``input/*.csv`` layout the input_writer historically emitted. Both
41
+ abstractions coexist; the Backend serves the ``input_writer`` spec
42
+ contract, ``SpineDbReader`` continues to serve the fast-path.
43
+ Unification is a follow-up beyond Step 2.5.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import logging
48
+ from typing import Any, Iterable
49
+
50
+ import polars as pl
51
+
52
+ from flextool.common_utils.precision import format_scalar_for_csv
53
+ from flextool.spinedb_backend._axis_enums import (
54
+ AxisContract,
55
+ cast_against_contract,
56
+ load_axis_contract,
57
+ )
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Structural-parameter skip list — preserved verbatim from
62
+ # ``input_writer.write_parameter`` (lines 2166-2191). These parameter
63
+ # names hold method names / structural identifiers / boolean flags
64
+ # rather than numerical coefficients; precision rounding would corrupt
65
+ # the model, so the effective precision is forced to 0 (passthrough).
66
+ # ---------------------------------------------------------------------------
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # v56 Batch F — three entity classes carry an explicit ``is_enabled``
70
+ # parameter that replaces the pre-v56 ``entity_alternative.active``
71
+ # gating. The Backend post-filters entities/parameter_values of these
72
+ # three classes against the resolved ``is_enabled`` value in the active
73
+ # scenario. See ``flextool.update_flextool.db_migration.
74
+ # _migrate_v56_reactivate_is_enabled_parameter`` for the migration that
75
+ # materialises the parameter on legacy DBs.
76
+ # ---------------------------------------------------------------------------
77
+
78
+ _IS_ENABLED_CLASSES: frozenset[str] = frozenset({
79
+ "constraint",
80
+ "reserve__upDown__unit__node",
81
+ "reserve__upDown__connection__node",
82
+ })
83
+
84
+
85
+ _STRUCTURAL_PARAM_NAMES: frozenset[str] = frozenset({
86
+ # method names
87
+ "ct_method", "transfer_method", "conversion_method",
88
+ "startup_method", "fork_method", "inflow_method",
89
+ "invest_method", "lifetime_method", "ramp_method",
90
+ "minimum_time_method", "storage_binding_method",
91
+ "storage_nested_fix_method", "storage_solve_horizon_method",
92
+ "storage_start_end_method", "profile_method", "reserve_method",
93
+ "co2_method", "loss_share_type", "price_method",
94
+ "capacity_margin_method", "energy_margin_method",
95
+ "solver", "solve_mode",
96
+ "solver_precommand", "solver_arguments",
97
+ # structural flags / references
98
+ "is_DC", "sense", "node_type",
99
+ "has_inertia", "has_non_synchronous", "include_stochastics",
100
+ "print_dispatch", "print_indicators",
101
+ "flow_aggregator",
102
+ "output_horizon",
103
+ # set membership / name references
104
+ "solves", "contains_solves", "model",
105
+ "realized_periods", "realized_invest_periods",
106
+ "fix_storage_periods", "invest_periods", "periods_available",
107
+ "version",
108
+ })
109
+
110
+
111
+ class SpineDBBackend:
112
+ """In-memory SpineDB materialiser for the input_writer spec contract.
113
+
114
+ Opens the database, applies the scenario filter once, and
115
+ pre-fetches the entity / parameter_value tables. Each materialiser
116
+ method returns a polars frame matching the canonical CSV schema
117
+ that the legacy ``input/*.csv`` files used (column names in
118
+ ``spec["header"]`` order).
119
+
120
+ Parameters
121
+ ----------
122
+ db_url : str
123
+ SpineDB URL. Bare paths are auto-prefixed with ``sqlite:///``.
124
+ scenario_name : str | None
125
+ Scenario filter name. ``None`` disables filtering (matches the
126
+ ``write_input`` semantics: when no scenario is supplied the DB
127
+ is consumed as-is).
128
+ precision_digits : int, default 0
129
+ Precision-rounding policy passed to
130
+ :func:`format_scalar_for_csv` during parameter materialisation.
131
+ ``0`` disables rounding (byte-parity with the pre-precision-pass
132
+ emissions).
133
+
134
+ Notes
135
+ -----
136
+ The Backend holds an open :class:`spinedb_api.DatabaseMapping` only
137
+ inside the ``with``-block. Use it as a context manager:
138
+
139
+ .. code-block:: python
140
+
141
+ with SpineDBBackend(db_url, scenario) as backend:
142
+ frame = backend.entities(
143
+ classes=("commodity",),
144
+ header=("commodity",),
145
+ )
146
+ """
147
+
148
+ # Class-level defaults so ``__new__`` paths (e.g.
149
+ # ``flextool.input_derivation.run`` bypasses ``__init__`` and attaches
150
+ # ``_db`` / ``_api`` / ``_precision_digits`` manually) still expose the
151
+ # attributes Phase-2 cast code reads. ``_scenario_name`` defaults to
152
+ # ``None`` (unfiltered backend); callers that *do* want a scenario
153
+ # filter should construct via ``__init__``.
154
+ _scenario_name: str | None = None
155
+ _db_url: str = ""
156
+ # Lazy cache for parameter_value rows, keyed by
157
+ # ``(entity_class_name, parameter_definition_name)``. Populated by
158
+ # :meth:`_get_parameter_value_index` on first ``parameter_values``
159
+ # call. Replaces ~100 individual ``find_parameter_values(class=,
160
+ # param=)`` calls (each ~1.85s under spinedb-api's scenario filter on
161
+ # large databases) with one bulk ``find_parameter_values()`` call
162
+ # plus a Python partition (~22ms total) — a ~60x speed-up.
163
+ _parameter_value_index: dict | None = None
164
+ # v56 Batch F — lazy cache of ``frozenset[int]`` disabled-entity ids
165
+ # per entity_class in ``_IS_ENABLED_CLASSES``. Populated on the first
166
+ # ``find_entities`` / ``entities`` / ``parameter_values`` call for
167
+ # that class. Lifetime is tied to ``_open`` / ``close`` of this
168
+ # Backend instance (single scenario filter).
169
+ _disabled_entity_id_cache: dict | None = None
170
+
171
+ def __init__(
172
+ self,
173
+ db_url: str,
174
+ scenario_name: str | None = None,
175
+ *,
176
+ precision_digits: int = 0,
177
+ ) -> None:
178
+ url = str(db_url)
179
+ if "://" not in url:
180
+ url = f"sqlite:///{url}"
181
+ self._db_url = url
182
+ self._scenario_name = scenario_name
183
+ self._precision_digits = int(precision_digits)
184
+ self._db: Any | None = None
185
+ self._open()
186
+
187
+ # ------------------------------------------------------------------
188
+ # Lifecycle
189
+ # ------------------------------------------------------------------
190
+
191
+ def _open(self) -> None:
192
+ """Open the DB, apply the scenario filter, pre-fetch entities +
193
+ parameter values.
194
+
195
+ spinedb-api now requires the ``DatabaseMapping`` to be inside a
196
+ ``with`` block before ``self.query()`` works — the underlying
197
+ ``Session`` is only created in ``__enter__``. Pre-v56 code
198
+ could call ``scenario_filter_from_dict`` against a freshly
199
+ constructed ``DatabaseMapping``; modern spinedb-api raises
200
+ ``SpineDBAPIError("session is None; did you forget to use the
201
+ DB map inside a 'with' block?")``. We therefore enter the
202
+ mapping's context here and keep it open for the lifetime of
203
+ this Backend — :meth:`close` exits the context. The
204
+ ``_context_open_count`` machinery in ``DatabaseMapping`` lets
205
+ nested call sites (e.g. ``fetch_all`` → internal ``with self``)
206
+ re-enter without closing prematurely.
207
+ """
208
+ import spinedb_api as api # late import: keeps cold-import cheap
209
+ from spinedb_api import DatabaseMapping
210
+
211
+ self._api = api
212
+ db = DatabaseMapping(self._db_url)
213
+ # Enter the mapping's context so ``self._session`` is created.
214
+ # Required by ``scenario_filter_from_dict`` (which calls
215
+ # ``db.query()`` while building the filter's subqueries) and by
216
+ # downstream ``find_*`` calls. Matched by :meth:`close`.
217
+ db.__enter__()
218
+ # ``fetch_all`` mirrors ``input_writer.write_input`` (line 1836-1837).
219
+ # Pre-fetching here means subsequent ``find_*`` calls are pure
220
+ # in-memory walks.
221
+ db.fetch_all("entity")
222
+ db.fetch_all("parameter_value")
223
+ if self._scenario_name:
224
+ scen_config = api.filters.scenario_filter.scenario_filter_config(
225
+ self._scenario_name,
226
+ )
227
+ api.filters.scenario_filter.scenario_filter_from_dict(db, scen_config)
228
+ self._db = db
229
+
230
+ def close(self) -> None:
231
+ """Close the underlying DB connection. Idempotent."""
232
+ if self._db is not None:
233
+ # Exit the mapping's context manager entered by :meth:`_open`,
234
+ # so the Session that was created in ``__enter__`` is closed.
235
+ try:
236
+ self._db.__exit__(None, None, None)
237
+ except Exception:
238
+ pass
239
+ try:
240
+ self._db.connection.close()
241
+ except Exception:
242
+ pass
243
+ try:
244
+ self._db.close()
245
+ except Exception:
246
+ pass
247
+ self._db = None
248
+ self._parameter_value_index = None
249
+ self._disabled_entity_id_cache = None
250
+
251
+ def __enter__(self) -> "SpineDBBackend":
252
+ if self._db is None:
253
+ self._open()
254
+ return self
255
+
256
+ def __exit__(self, *exc: Any) -> None:
257
+ self.close()
258
+
259
+ # ------------------------------------------------------------------
260
+ # Diagnostic properties
261
+ # ------------------------------------------------------------------
262
+
263
+ @property
264
+ def db_url(self) -> str:
265
+ return self._db_url
266
+
267
+ @property
268
+ def scenario_name(self) -> str | None:
269
+ return self._scenario_name
270
+
271
+ def __repr__(self) -> str: # pragma: no cover - trivial
272
+ return (
273
+ f"SpineDBBackend(db_url={self._db_url!r}, "
274
+ f"scenario_name={self._scenario_name!r})"
275
+ )
276
+
277
+ # ------------------------------------------------------------------
278
+ # Raw EAV accessors — for input_derivation consumers
279
+ # ------------------------------------------------------------------
280
+
281
+ def find_entities(self, *, entity_class_name: str) -> list:
282
+ """Return all entity rows of *entity_class_name*.
283
+
284
+ Thin passthrough over the underlying
285
+ :meth:`spinedb_api.DatabaseMapping.find_entities`, exposed so
286
+ consumers in :mod:`flextool.input_derivation` (DC power flow,
287
+ process method, commodity ladder) can perform DB-driven
288
+ derivations without reaching past the Backend boundary.
289
+
290
+ v56 Batch F — for the three entity classes in
291
+ :data:`_IS_ENABLED_CLASSES` the result is post-filtered against
292
+ the explicit ``is_enabled`` parameter_value: entities whose
293
+ ``is_enabled`` resolves to ``"no"`` in the active scenario are
294
+ dropped. Replaces the entity_alternative-based gating that
295
+ spinedb_api's scenario_filter applied pre-v56 (the migration
296
+ drops those rows for the three classes).
297
+ """
298
+ if self._db is None:
299
+ raise RuntimeError("SpineDBBackend is closed")
300
+ entities = list(
301
+ self._db.find_entities(entity_class_name=entity_class_name)
302
+ )
303
+ if entity_class_name in _IS_ENABLED_CLASSES:
304
+ disabled_ids = self._disabled_entity_ids(entity_class_name)
305
+ if disabled_ids:
306
+ entities = [e for e in entities if e["id"] not in disabled_ids]
307
+ return entities
308
+
309
+ def _disabled_entity_ids(self, entity_class_name: str) -> frozenset[int]:
310
+ """Return entity IDs of *entity_class_name* whose effective
311
+ ``is_enabled`` parameter_value is ``"no"`` under the active
312
+ scenario filter.
313
+
314
+ Lazy-cached per class on first call. The scenario filter is
315
+ applied DB-wide at ``_open`` time (see
316
+ :meth:`scenario_filter_from_dict`), so
317
+ :meth:`spinedb_api.DatabaseMapping.find_parameter_values` already
318
+ returns the scenario-priority winner per (entity, parameter):
319
+ one row per entity, with the highest-priority alternative's
320
+ value resolved. We treat any returned row carrying
321
+ ``parsed_value == "no"`` as disabling its entity.
322
+
323
+ On a pre-v56 database the ``is_enabled`` parameter_definition
324
+ does not exist; ``find_parameter_values`` then yields nothing
325
+ and every entity is treated as enabled — preserving legacy
326
+ behaviour for callers that open an un-migrated DB read-only.
327
+ """
328
+ cache = self._disabled_entity_id_cache
329
+ if cache is None:
330
+ cache = {}
331
+ self._disabled_entity_id_cache = cache
332
+ if entity_class_name in cache:
333
+ return cache[entity_class_name]
334
+ if self._db is None:
335
+ raise RuntimeError("SpineDBBackend is closed")
336
+ try:
337
+ pv_rows = self._db.find_parameter_values(
338
+ entity_class_name=entity_class_name,
339
+ parameter_definition_name="is_enabled",
340
+ )
341
+ except Exception: # noqa: BLE001 — pre-v56 DBs lack the pdef
342
+ pv_rows = []
343
+ disabled: set[int] = set()
344
+ for pv in pv_rows:
345
+ if pv.get("type") != "str":
346
+ continue
347
+ if pv.get("parsed_value") == "no":
348
+ disabled.add(pv["entity_id"])
349
+ result = frozenset(disabled)
350
+ cache[entity_class_name] = result
351
+ return result
352
+
353
+ def find_parameter_values(
354
+ self,
355
+ *,
356
+ entity_class_name: str,
357
+ parameter_definition_name: str,
358
+ ) -> list:
359
+ """Return all parameter-value rows for *(entity_class_name,
360
+ parameter_definition_name)*.
361
+
362
+ Same role as :meth:`find_entities`: a Backend-owned passthrough
363
+ so :mod:`flextool.input_derivation` derivations don't have to
364
+ re-implement the DB access layer.
365
+ """
366
+ if self._db is None:
367
+ raise RuntimeError("SpineDBBackend is closed")
368
+ return self._db.find_parameter_values(
369
+ entity_class_name=entity_class_name,
370
+ parameter_definition_name=parameter_definition_name,
371
+ )
372
+
373
+ @property
374
+ def api(self):
375
+ """Expose the underlying :mod:`spinedb_api` module.
376
+
377
+ Several derivations need ``api.convert_map_to_table`` /
378
+ ``api.parameter_value.from_database_to_dimension_count`` to
379
+ flatten nested Spine maps. Re-exposing the same module the
380
+ Backend imported avoids a duplicate import in every derivation
381
+ site (and keeps that import path single-sourced).
382
+ """
383
+ if self._db is None:
384
+ raise RuntimeError("SpineDBBackend is closed")
385
+ return self._api
386
+
387
+ # ==================================================================
388
+ # Materialiser methods — fully filled in by Items 2-4 of the work
389
+ # plan. Each method returns a polars DataFrame; the caller is
390
+ # responsible for placing it in the Provider under the canonical
391
+ # frame key.
392
+ # ==================================================================
393
+
394
+ # ------------------------------------------------------------------
395
+ # parameter_defaults — Item 2
396
+ # ------------------------------------------------------------------
397
+
398
+ def parameter_defaults(
399
+ self,
400
+ *,
401
+ cl_pars: Iterable[tuple[str, str]],
402
+ header: str,
403
+ filter_in_type: Iterable[str] | None = None,
404
+ only_value: bool = False,
405
+ axis_enums: dict[str, pl.Enum] | None = None,
406
+ contract: AxisContract | None = None,
407
+ ) -> pl.DataFrame:
408
+ """Materialise the default-value rows for one ``_DEFAULT_VALUES_SPECS`` entry.
409
+
410
+ Returns a polars frame.
411
+
412
+ Parameters
413
+ ----------
414
+ cl_pars : iterable of (entity_class, parameter_name) tuples
415
+ The parameter-definition (class, name) pairs to materialise.
416
+ header : str
417
+ Comma-separated header for the canonical CSV layout. The
418
+ frame's columns are derived from this header so downstream
419
+ consumers see byte-identical schemas.
420
+ filter_in_type : iterable of str, optional
421
+ Restrict to parameter definitions whose ``default_type`` is
422
+ in this set. ``None`` accepts every supported scalar type.
423
+ only_value : bool, default False
424
+ When True the frame contains a single column (the value
425
+ only). Used by the ``db_version`` spec where the canonical
426
+ CSV is a one-column scalar dump.
427
+ """
428
+ if self._db is None:
429
+ raise RuntimeError("SpineDBBackend is closed")
430
+ cl_pars = list(cl_pars)
431
+ cols = [c.strip() for c in header.split(",")]
432
+ filter_set = (
433
+ set(filter_in_type) if filter_in_type is not None else None
434
+ )
435
+
436
+ precision = self._precision_digits
437
+ # Match write_default_values' structural-param policy: when the
438
+ # default is one of the structural param names the precision is
439
+ # forced to 0 so the emitted text round-trips byte-for-byte with
440
+ # the on-disk legacy CSV. (The legacy code did not branch on
441
+ # the structural set for defaults — but the params in the only
442
+ # default_values spec entry are ``penalty_up`` / ``penalty_down``
443
+ # which are pure floats, so the policy is a strict superset.)
444
+ if any(par in _STRUCTURAL_PARAM_NAMES for _, par in cl_pars):
445
+ precision = 0
446
+
447
+ api = self._api
448
+ # ``find_parameter_definitions`` returns ALL definitions; filter
449
+ # locally by (class, name) to mirror write_default_values exactly.
450
+ definitions = self._db.find_parameter_definitions()
451
+ rows: list[list[str]] = []
452
+ # Origin breadcrumbs parallel to ``rows`` (Option C) — each entry
453
+ # is a dict captured at row-emit time so a downstream
454
+ # FlexDataIntegrityError can name the parameter / class that
455
+ # generated the offending row.
456
+ row_origins: list[dict[str, Any]] = []
457
+ from flextool.engine_polars._solve_state import FlexToolConfigError
458
+
459
+ for cl_par in cl_pars:
460
+ for definition in definitions:
461
+ if (
462
+ definition["entity_class_name"] != cl_par[0]
463
+ or definition["name"] != cl_par[1]
464
+ ):
465
+ continue
466
+ if (
467
+ filter_set is not None
468
+ and definition["default_type"] not in filter_set
469
+ ):
470
+ continue
471
+ dtype = definition["default_type"]
472
+ if dtype in ("str", "float", "bool"):
473
+ raw = api.from_database(
474
+ definition["default_value"], dtype,
475
+ )
476
+ formatted = format_scalar_for_csv(raw, precision)
477
+ if only_value:
478
+ rows.append([formatted])
479
+ else:
480
+ rows.append([
481
+ definition["entity_class_name"],
482
+ definition["name"],
483
+ formatted,
484
+ ])
485
+ row_origins.append({
486
+ "parameter": definition["name"],
487
+ "entity": definition["entity_class_name"],
488
+ "scenario": self._scenario_name,
489
+ })
490
+ else:
491
+ raise FlexToolConfigError(
492
+ "Default_value found in a parameter definition not "
493
+ "of supported default type\nParameter: "
494
+ + definition.get("parameter_definition_name", definition["name"])
495
+ )
496
+
497
+ frame = _rows_to_frame(rows, cols)
498
+ return self._maybe_cast(
499
+ frame, rows, row_origins, axis_enums, contract,
500
+ )
501
+
502
+ # ------------------------------------------------------------------
503
+ # entities — Item 3
504
+ # ------------------------------------------------------------------
505
+
506
+ def entities(
507
+ self,
508
+ *,
509
+ classes: Iterable[str],
510
+ header: str,
511
+ entity_dimens: Iterable[Iterable[int]] | None = None,
512
+ axis_enums: dict[str, pl.Enum] | None = None,
513
+ contract: AxisContract | None = None,
514
+ ) -> pl.DataFrame:
515
+ """Materialise the entity rows for one ``_ENTITY_SPECS`` entry as a polars frame.
516
+
517
+ Parameters
518
+ ----------
519
+ classes : iterable of str
520
+ The SpineDB entity-class names to union over. Each class
521
+ contributes its full entity set; the result is the
522
+ row-concatenation (in class-iteration order).
523
+ header : str
524
+ Comma-separated header for the canonical CSV layout.
525
+ entity_dimens : iterable of iterable of int, optional
526
+ Per-class re-ordering / projection of the
527
+ ``entity_byname`` tuple — list of integer index lists, one
528
+ per class. Used to disambiguate multi-dimensional
529
+ entity-class joins where the same dim appears more than
530
+ once (e.g. ``connection__node__node``). ``None`` keeps the
531
+ byname tuple as-is.
532
+ """
533
+ if self._db is None:
534
+ raise RuntimeError("SpineDBBackend is closed")
535
+ classes_list = list(classes)
536
+ dimens_list: list[list[int] | None]
537
+ if entity_dimens is None:
538
+ dimens_list = [None] * len(classes_list)
539
+ else:
540
+ dimens_list = [list(d) if d is not None else None
541
+ for d in entity_dimens]
542
+ cols = [c.strip() for c in header.split(",")]
543
+
544
+ rows: list[list[str]] = []
545
+ row_origins: list[dict[str, Any]] = []
546
+ for i, ent_class in enumerate(classes_list):
547
+ dim_proj = dimens_list[i] if i < len(dimens_list) else None
548
+ # v56 Batch F — filter is_enabled="no" entities out of the
549
+ # three affected classes. Replaces the entity_alternative
550
+ # gating spinedb_api's scenario_filter applied pre-v56.
551
+ disabled_ids: frozenset[int] = frozenset()
552
+ if ent_class in _IS_ENABLED_CLASSES:
553
+ disabled_ids = self._disabled_entity_ids(ent_class)
554
+ for entity in self._db.find_entities(entity_class_name=ent_class):
555
+ if disabled_ids and entity["id"] in disabled_ids:
556
+ continue
557
+ byname = entity["entity_byname"]
558
+ if dim_proj is None:
559
+ rows.append(list(byname))
560
+ else:
561
+ rows.append([byname[x] for x in dim_proj])
562
+ row_origins.append({
563
+ "parameter": None,
564
+ "entity": "->".join(str(b) for b in byname),
565
+ "scenario": self._scenario_name,
566
+ "entity_class": ent_class,
567
+ })
568
+ frame = _rows_to_frame(rows, cols)
569
+ return self._maybe_cast(
570
+ frame, rows, row_origins, axis_enums, contract,
571
+ )
572
+
573
+ # ------------------------------------------------------------------
574
+ # parameter_values — Item 4
575
+ # ------------------------------------------------------------------
576
+
577
+ def parameter_values(
578
+ self,
579
+ *,
580
+ cl_pars: Iterable[tuple[str, str]],
581
+ header: str,
582
+ filter_in_type: Iterable[str] | None = None,
583
+ filter_out_index: str | None = None,
584
+ filter_in_value: Any = None,
585
+ no_value: bool = False,
586
+ param_print: bool = False,
587
+ dimens: Iterable[int] | None = None,
588
+ param_loc: int | None = None,
589
+ no_entity: bool | None = None,
590
+ axis_enums: dict[str, pl.Enum] | None = None,
591
+ contract: AxisContract | None = None,
592
+ ) -> pl.DataFrame:
593
+ """Materialise the parameter-value rows for one ``_PARAMETER_SPECS`` entry.
594
+
595
+ Handles the 1d / 2d / … map dimensionality logic, type / value /
596
+ index filters, the structural-parameter precision policy, the
597
+ ``no_value`` /
598
+ ``no_entity`` / ``param_print`` / ``param_loc`` schema shaping
599
+ flags — but returns a polars frame instead of writing CSV.
600
+ """
601
+ if self._db is None:
602
+ raise RuntimeError("SpineDBBackend is closed")
603
+ from flextool.engine_polars._solve_state import FlexToolConfigError
604
+
605
+ cl_pars = list(cl_pars)
606
+ cols = [c.strip() for c in header.split(",")]
607
+ dimens_list = list(dimens) if dimens is not None else None
608
+
609
+ # Effective precision per the structural-param policy.
610
+ effective_precision = (
611
+ 0 if any(par in _STRUCTURAL_PARAM_NAMES for _, par in cl_pars)
612
+ else self._precision_digits
613
+ )
614
+
615
+ # Map-dimensionality filter parsing — exactly write_parameter's
616
+ # rule. At most one ``Nd_map`` entry; if present, also accept
617
+ # the generic ``map`` type code via the filter.
618
+ type_filter_map_dim: int | list = []
619
+ filter_in_type_list: list[str] | None = None
620
+ if filter_in_type is not None:
621
+ filter_in_type_list = list(filter_in_type)
622
+ map_found = False
623
+ for tf in list(filter_in_type_list):
624
+ if tf in ("1d_map", "2d_map", "3d_map", "4d_map", "5d_map"):
625
+ if map_found:
626
+ message = (
627
+ "Trying to have two different dimensionalities in "
628
+ "the same parameter to be written out"
629
+ )
630
+ logging.error(message)
631
+ raise FlexToolConfigError(message)
632
+ map_found = True
633
+ type_filter_map_dim = int(tf[0])
634
+ filter_in_type_list.remove(tf)
635
+ if map_found:
636
+ filter_in_type_list.append("map")
637
+
638
+ api = self._api
639
+
640
+ # Use the cached (class, param) → list[param] index built by
641
+ # ``_get_parameter_value_index`` — one bulk ``find_parameter_values()``
642
+ # call instead of N filtered ones. Each filtered call is
643
+ # ~1.85s under spinedb-api's scenario filter on large fixtures;
644
+ # the bulk call + Python partition is ~2.8s total regardless of
645
+ # N, so once N > ~2 this is a net win.
646
+ index = self._get_parameter_value_index()
647
+ params: list = []
648
+ # v56 Batch F — when a (class, param) pair belongs to an
649
+ # is_enabled-gated class, drop parameter_value rows for entities
650
+ # whose ``is_enabled`` resolves to ``"no"`` in the active
651
+ # scenario. Without this filter the cascade would re-introduce
652
+ # disabled entities through their non-is_enabled
653
+ # parameter_values (e.g. ``constraint.constant`` /
654
+ # ``reserve__upDown__unit__node.reliability``).
655
+ for cl_par in cl_pars:
656
+ bucket = index.get((cl_par[0], cl_par[1]), ())
657
+ ent_class = cl_par[0]
658
+ if ent_class in _IS_ENABLED_CLASSES:
659
+ disabled_ids = self._disabled_entity_ids(ent_class)
660
+ if disabled_ids:
661
+ bucket = [
662
+ p for p in bucket
663
+ if p["entity_id"] not in disabled_ids
664
+ ]
665
+ params.extend(bucket)
666
+
667
+ # Track A.6 — chunked row accumulator. Some specs (notably
668
+ # ``('profile', 'profile')`` on H2_trade) flatten to multi-million
669
+ # Python row-lists, which dominate input_derivation peak RSS even
670
+ # after Track A + A.5. Flushing the row accumulator to a polars
671
+ # sub-frame periodically and clearing the list keeps the
672
+ # in-flight Python overhead bounded to ``_ROWS_FLUSH_THRESHOLD``
673
+ # entries. The sub-frames are concatenated at end; for ≤1 chunk
674
+ # the path is identical to the pre-chunking code.
675
+ #
676
+ # Disabled when ``axis_enums`` is supplied: ``_maybe_cast``'s
677
+ # error-breadcrumb logic walks ``rows`` and ``row_origins`` in
678
+ # parallel, so partial-rows views would mis-attribute cast
679
+ # failures. axis_enum-supplied callsites are tests with small
680
+ # fixtures where the peak doesn't bind; safe to fall back.
681
+ _ROWS_FLUSH_THRESHOLD = 200_000
682
+ _can_chunk = axis_enums is None
683
+ chunk_frames: list[pl.DataFrame] = []
684
+
685
+ rows: list[list[str]] = []
686
+ # Origin breadcrumbs parallel to ``rows`` (Option C). Each
687
+ # entry is a dict of (parameter, entity, scenario, map_index)
688
+ # captured at row-emit time. Map rows append the map index
689
+ # specifically; scalar / array rows leave map_index as None.
690
+ #
691
+ # Track A.5 — these breadcrumbs are *only* consulted by
692
+ # :meth:`_maybe_cast` when an axis-enum cast fails. When
693
+ # ``axis_enums`` is ``None`` (the default for the
694
+ # ``input_derivation`` callsites) ``_maybe_cast`` returns the
695
+ # frame untouched and never reads ``row_origins``. Building 3+
696
+ # million-entry dict lists for that path is pure waste, so we
697
+ # short-circuit append to a no-op when no cast is requested.
698
+ # For axis-enum-enabled callsites the full origin dict list is
699
+ # still constructed and the error breadcrumb quality is
700
+ # unchanged.
701
+ row_origins: list[dict[str, Any]] = []
702
+ if axis_enums is None:
703
+ def _origin_append(_origin: dict[str, Any]) -> None:
704
+ pass
705
+ else:
706
+ _origin_append = row_origins.append # type: ignore[assignment]
707
+ scen = self._scenario_name
708
+ # Track A — wrap ``params`` so each :class:`spinedb_api.PublicItem`
709
+ # has its underlying ``MappedItem._parsed_value`` set to ``None``
710
+ # as iteration advances past it. ``parsed_value`` is a lazy
711
+ # property in spinedb-api: dropping the cached object releases the
712
+ # parsed ``Map`` / ``TimeSeries`` / ``Array`` and the next access
713
+ # would re-parse via ``from_database(value, type)``. Each
714
+ # ``(class, param)`` is touched exactly once per
715
+ # ``input_derivation`` pass, so this gives the full memory win at
716
+ # zero re-parse cost. The wrapper is safe even when callers
717
+ # ``continue`` mid-iteration: the next ``next()`` call enters the
718
+ # generator body BEFORE yielding the new item, so the previously
719
+ # yielded item is evicted no matter how the caller exited the
720
+ # body. At any moment at most one parsed_value lives.
721
+ # ``tests/spinedb_backend/test_parsed_value_eviction.py`` enforces
722
+ # this contract and warns loudly on spinedb-api API drift.
723
+
724
+ def _evict_as_we_go(items):
725
+ prev = None
726
+ for item in items:
727
+ if prev is not None:
728
+ prev.mapped_item._parsed_value = None
729
+ prev = item
730
+ yield item
731
+ if prev is not None:
732
+ prev.mapped_item._parsed_value = None
733
+
734
+ for param in _evict_as_we_go(params):
735
+ if _can_chunk and len(rows) >= _ROWS_FLUSH_THRESHOLD:
736
+ # Flush the in-flight rows into a sub-frame and start
737
+ # fresh. ``rows = []`` rebinds; the previous list
738
+ # becomes garbage as soon as ``_rows_to_frame`` returns.
739
+ chunk_frames.append(_rows_to_frame(rows, cols))
740
+ rows = []
741
+ if param["type"] is None:
742
+ continue
743
+ if filter_in_type_list and param["type"] not in filter_in_type_list:
744
+ continue
745
+
746
+ entity_byname = list(param["entity_byname"])
747
+ if dimens_list is not None:
748
+ tmp: list[Any] = [None] * len(entity_byname)
749
+ for i, dim in enumerate(dimens_list):
750
+ tmp[dim] = entity_byname[i]
751
+ entity_byname = tmp
752
+
753
+ if param_print:
754
+ if param_loc is not None:
755
+ collect = []
756
+ for i, byname in enumerate(entity_byname):
757
+ if i == param_loc:
758
+ collect.append(param["parameter_definition_name"])
759
+ collect.append(byname)
760
+ first_cols = collect
761
+ else:
762
+ if no_entity:
763
+ first_cols = [param["parameter_definition_name"]]
764
+ else:
765
+ first_cols = list(entity_byname) + [
766
+ param["parameter_definition_name"]
767
+ ]
768
+ else:
769
+ first_cols = list(entity_byname)
770
+
771
+ pname = param["parameter_definition_name"]
772
+ ent_str = "->".join(
773
+ str(b) for b in param["entity_byname"]
774
+ )
775
+
776
+ ptype = param["type"]
777
+ if ptype == "map":
778
+ if (
779
+ filter_out_index
780
+ and param["parsed_value"].index_name == filter_out_index
781
+ ):
782
+ continue
783
+ if (
784
+ filter_in_type_list
785
+ and isinstance(type_filter_map_dim, int)
786
+ and type_filter_map_dim
787
+ != api.parameter_value.from_database_to_dimension_count(
788
+ param["value"], param["type"],
789
+ )
790
+ ):
791
+ continue
792
+ value = param["parsed_value"]
793
+ if (
794
+ api.parameter_value.from_database_to_dimension_count(
795
+ param["value"], param["type"],
796
+ ) <= 1
797
+ ):
798
+ indexes = [str(ind) for ind in value.indexes]
799
+ pairs = list(zip(
800
+ indexes,
801
+ [format_scalar_for_csv(v, effective_precision)
802
+ for v in value.values],
803
+ ))
804
+ for idx, val in pairs:
805
+ if no_value:
806
+ rows.append(first_cols + [idx])
807
+ else:
808
+ rows.append(first_cols + [idx, val])
809
+ _origin_append({
810
+ "parameter": pname,
811
+ "entity": ent_str,
812
+ "scenario": scen,
813
+ "map_index": idx,
814
+ })
815
+ else:
816
+ flat_map = api.convert_map_to_table(value)
817
+ for index in flat_map:
818
+ row = list(index)
819
+ if no_value:
820
+ rows.append(first_cols + row[:-1])
821
+ else:
822
+ row[-1] = format_scalar_for_csv(
823
+ row[-1], effective_precision,
824
+ )
825
+ rows.append(first_cols + row)
826
+ # The map index path is the row content before
827
+ # the value; render it as a joined-string for
828
+ # the breadcrumb. Use index[:-1] to drop the
829
+ # leaf value.
830
+ idx_path = "->".join(
831
+ str(x) for x in list(index)[:-1]
832
+ )
833
+ _origin_append({
834
+ "parameter": pname,
835
+ "entity": ent_str,
836
+ "scenario": scen,
837
+ "map_index": idx_path,
838
+ })
839
+ elif ptype in ("array", "time_series"):
840
+ # Reject array-valued ``node.storage_binding_method`` at
841
+ # ingestion. The 2026-04 list-valued design (now reverted)
842
+ # silently flattened arrays into one row per element,
843
+ # which downstream additive logic in
844
+ # ``calc_storage_vre.py`` and the constraint emitter
845
+ # turned into double-counted state-change residuals.
846
+ # Single-valued is the contract; arrays are a hard error
847
+ # with an actionable message pointing at the v52->v53
848
+ # migration (added in Phase 2).
849
+ if (
850
+ param["entity_class_name"] == "node"
851
+ and pname == "storage_binding_method"
852
+ ):
853
+ try:
854
+ arr_values = list(param["parsed_value"].values)
855
+ except Exception:
856
+ arr_values = []
857
+ arr_repr = "[" + ", ".join(
858
+ repr(v) for v in arr_values
859
+ ) + "]"
860
+ allowed = [
861
+ "bind_within_solve_blended_weights",
862
+ "bind_intraperiod_blocks",
863
+ "bind_within_solve",
864
+ "bind_within_period",
865
+ "bind_within_timeblock",
866
+ "bind_forward_only",
867
+ ]
868
+ message = (
869
+ "node.storage_binding_method must be a single "
870
+ "string, not an array.\n"
871
+ f"Offending entity: {ent_str}\n"
872
+ f"Array contents: {arr_repr}\n"
873
+ f"Allowed single-string values: {allowed}\n"
874
+ "Pick `bind_within_solve_blended_weights` if the node "
875
+ "uses representative-period blended weights, "
876
+ "otherwise pick the dominant non-RP method from "
877
+ "your array.\n"
878
+ "Run the v52->v53 migration tool (added in "
879
+ "Phase 2) to convert existing databases "
880
+ "automatically."
881
+ )
882
+ logging.error(message)
883
+ raise FlexToolConfigError(message)
884
+ for i_arr, v in enumerate(param["parsed_value"].values):
885
+ rows.append(
886
+ list(entity_byname)
887
+ + [format_scalar_for_csv(v, effective_precision)]
888
+ )
889
+ _origin_append({
890
+ "parameter": pname,
891
+ "entity": ent_str,
892
+ "scenario": scen,
893
+ "map_index": str(i_arr),
894
+ })
895
+ elif ptype in ("str", "float", "bool"):
896
+ if filter_in_value is not None:
897
+ # A set/frozenset → membership test (v58 enum gating, e.g.
898
+ # flow_aggregator ∈ {dispatch_plots_only, both}); a scalar →
899
+ # exact match (backward compatible).
900
+ if isinstance(filter_in_value, (set, frozenset)):
901
+ if param["parsed_value"] not in filter_in_value:
902
+ continue
903
+ elif param["parsed_value"] != filter_in_value:
904
+ continue
905
+ if no_value:
906
+ rows.append(first_cols)
907
+ else:
908
+ rows.append(
909
+ first_cols
910
+ + [format_scalar_for_csv(
911
+ param["parsed_value"], effective_precision,
912
+ )]
913
+ )
914
+ _origin_append({
915
+ "parameter": pname,
916
+ "entity": ent_str,
917
+ "scenario": scen,
918
+ })
919
+ else:
920
+ supported = (
921
+ filter_in_type_list
922
+ if filter_in_type_list
923
+ else ["bool", "str", "float", "array", "time_series", "map"]
924
+ )
925
+ message = (
926
+ f"Input data found in a parameter not of supported type."
927
+ f"\nEntity: {','.join(map(str, entity_byname))}"
928
+ f"\nParameter: {param['parameter_definition_name']}"
929
+ f"\nSupported types: {supported}"
930
+ f"\nParameter type: {param['type']}"
931
+ )
932
+ logging.error(message)
933
+ raise FlexToolConfigError(message)
934
+
935
+ # Track A.6 — assemble the final frame from any flushed chunks
936
+ # plus the in-flight remainder. Single-chunk path is identical
937
+ # to the pre-chunking behaviour (one ``_rows_to_frame`` call,
938
+ # one ``_maybe_cast``). Multi-chunk path concatenates the
939
+ # sub-frames *before* the cast so ``_maybe_cast`` sees a single
940
+ # frame. ``row_origins`` is empty in the chunked path (Track A.5
941
+ # gated it to non-None axis_enums callers; chunked path is
942
+ # axis_enums-None only), so ``_maybe_cast``'s breadcrumb walk is
943
+ # a no-op there.
944
+ if chunk_frames:
945
+ if rows:
946
+ chunk_frames.append(_rows_to_frame(rows, cols))
947
+ rows = []
948
+ if len(chunk_frames) == 1:
949
+ frame = chunk_frames[0]
950
+ else:
951
+ frame = pl.concat(chunk_frames)
952
+ else:
953
+ frame = _rows_to_frame(rows, cols)
954
+ return self._maybe_cast(
955
+ frame, rows, row_origins, axis_enums, contract,
956
+ )
957
+
958
+ # ------------------------------------------------------------------
959
+ # Internal parameter_value cache
960
+ # ------------------------------------------------------------------
961
+
962
+ def _get_parameter_value_index(self) -> dict:
963
+ """Build (lazily) and return ``{(class, param): [param_rows]}``.
964
+
965
+ ``find_parameter_values(entity_class_name=, parameter_definition_name=)``
966
+ is ~1.85s per call under spinedb-api's scenario filter on large
967
+ fixtures — proportional to the in-memory parameter_value table,
968
+ not to the filter-selectivity. A bulk ``find_parameter_values()``
969
+ with no filter args returns *all* (scenario-filtered) rows in
970
+ ~2.8s on the same fixture and partitions in ~22ms. For the
971
+ ``input_derivation.run`` call shape (~100 individual filtered
972
+ calls per backend instance), the bulk-then-partition pattern
973
+ is ~60x faster.
974
+
975
+ The cache is invalidated by ``close()``. It is rebuilt
976
+ per-backend-instance, so callers that mutate the underlying
977
+ ``DatabaseMapping`` mid-flight (no current caller does this)
978
+ would see stale data — flag this if it ever becomes relevant.
979
+ """
980
+ if self._parameter_value_index is not None:
981
+ return self._parameter_value_index
982
+ if self._db is None:
983
+ raise RuntimeError("SpineDBBackend is closed")
984
+ all_params = self._db.find_parameter_values()
985
+ index: dict = {}
986
+ for p in all_params:
987
+ key = (p["entity_class_name"], p["parameter_definition_name"])
988
+ bucket = index.get(key)
989
+ if bucket is None:
990
+ index[key] = [p]
991
+ else:
992
+ bucket.append(p)
993
+ self._parameter_value_index = index
994
+ return index
995
+
996
+ # ------------------------------------------------------------------
997
+ # Internal cast helper
998
+ # ------------------------------------------------------------------
999
+
1000
+ def _maybe_cast(
1001
+ self,
1002
+ frame: pl.DataFrame,
1003
+ rows: list[list[Any]],
1004
+ row_origins: list[dict[str, Any]],
1005
+ axis_enums: dict[str, pl.Enum] | None,
1006
+ contract: AxisContract | None,
1007
+ ) -> pl.DataFrame:
1008
+ """Cast *frame* against the axis contract when *axis_enums* is
1009
+ non-None.
1010
+
1011
+ Origin breadcrumbs (Option C from the Phase 2 plan): the row
1012
+ builder kept a *row_origins* list parallel to *rows*; on a cast
1013
+ failure we identify the offending column / token by scanning
1014
+ the frame, then look up which row introduced that token and
1015
+ thread the row's origin dict into the raised
1016
+ :class:`FlexDataIntegrityError`.
1017
+
1018
+ When *axis_enums* is None the method is a no-op (pre-Phase-2
1019
+ behaviour: callers that don't opt-in see Utf8 frames).
1020
+ """
1021
+ if axis_enums is None:
1022
+ return frame
1023
+ if contract is None:
1024
+ contract = load_axis_contract()
1025
+ # First pass: identify bad (column, token) before raising so
1026
+ # we can find its row in row_origins.
1027
+ bad_col: str | None = None
1028
+ bad_token: str | None = None
1029
+ for col in frame.columns:
1030
+ axis = contract.column_to_axis(col)
1031
+ if axis is None:
1032
+ continue
1033
+ dtype = axis_enums.get(axis.name)
1034
+ if dtype is None:
1035
+ continue
1036
+ vocab = set(dtype.categories.to_list())
1037
+ for v in frame[col].to_list():
1038
+ if v is None or v == "":
1039
+ continue
1040
+ if v not in vocab:
1041
+ bad_col = col
1042
+ bad_token = str(v)
1043
+ break
1044
+ if bad_col is not None:
1045
+ break
1046
+
1047
+ if bad_col is None:
1048
+ # All values fit — let cast_against_contract do the cast.
1049
+ return cast_against_contract(
1050
+ frame,
1051
+ contract=contract,
1052
+ axis_enums=axis_enums,
1053
+ origin={"scenario": self._scenario_name},
1054
+ backend=self,
1055
+ )
1056
+
1057
+ # Identify the row that introduced the bad token to thread the
1058
+ # right breadcrumb into the error.
1059
+ origin_for_error: dict[str, Any] = {
1060
+ "scenario": self._scenario_name,
1061
+ }
1062
+ try:
1063
+ col_idx = frame.columns.index(bad_col)
1064
+ except ValueError: # pragma: no cover — defensive
1065
+ col_idx = -1
1066
+ if col_idx >= 0:
1067
+ for i, r in enumerate(rows):
1068
+ if col_idx < len(r) and str(r[col_idx]) == bad_token:
1069
+ if i < len(row_origins):
1070
+ origin_for_error = {
1071
+ **row_origins[i],
1072
+ "scenario": row_origins[i].get(
1073
+ "scenario", self._scenario_name,
1074
+ ),
1075
+ }
1076
+ break
1077
+
1078
+ # Re-run cast_against_contract with the located breadcrumb so
1079
+ # the FlexDataIntegrityError carries the precise origin.
1080
+ return cast_against_contract(
1081
+ frame,
1082
+ contract=contract,
1083
+ axis_enums=axis_enums,
1084
+ origin=origin_for_error,
1085
+ backend=self,
1086
+ )
1087
+
1088
+
1089
+ # ---------------------------------------------------------------------------
1090
+ # Internal helpers
1091
+ # ---------------------------------------------------------------------------
1092
+
1093
+
1094
+ def _rows_to_frame(rows: list[list[Any]], cols: list[str]) -> pl.DataFrame:
1095
+ """Build an all-Utf8 polars frame from string-row data.
1096
+
1097
+ The legacy CSVs are pure text — every cell is the result of
1098
+ :func:`format_scalar_for_csv` or a raw entity name. Reading them
1099
+ back yields :class:`polars.Utf8` columns; we materialise the
1100
+ frames with the same schema so downstream consumers see byte-
1101
+ identical bytes when the Provider is snapshotted to disk.
1102
+
1103
+ A row with fewer cells than ``cols`` is padded with empty strings;
1104
+ rows with more cells are truncated. In practice every row already
1105
+ matches ``len(cols)`` — the defensive padding documents the
1106
+ invariant.
1107
+ """
1108
+ schema = {c: pl.Utf8 for c in cols}
1109
+ if not rows:
1110
+ return pl.DataFrame(schema=schema)
1111
+ n = len(cols)
1112
+ norm: list[list[str]] = []
1113
+ for r in rows:
1114
+ if len(r) == n:
1115
+ norm.append([_to_str(x) for x in r])
1116
+ elif len(r) < n:
1117
+ norm.append([_to_str(x) for x in r] + [""] * (n - len(r)))
1118
+ else:
1119
+ norm.append([_to_str(x) for x in r[:n]])
1120
+ # Build column-oriented dict for cheap pl.DataFrame construction.
1121
+ data: dict[str, list[str]] = {c: [] for c in cols}
1122
+ for r in norm:
1123
+ for c, v in zip(cols, r):
1124
+ data[c].append(v)
1125
+ return pl.DataFrame(data, schema=schema)
1126
+
1127
+
1128
+ def _to_str(x: Any) -> str:
1129
+ """Coerce a row cell to its CSV-form string. Already-strings pass
1130
+ through; everything else goes via ``str(...)``.
1131
+ """
1132
+ if isinstance(x, str):
1133
+ return x
1134
+ if x is None:
1135
+ return ""
1136
+ return str(x)
1137
+
1138
+
1139
+ __all__ = ["SpineDBBackend"]