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,1165 @@
1
+ """Production :class:`InputSource` implementation backed by SpineDB.
2
+
3
+ Wraps :class:`spinedb_api.DatabaseMapping`, applies a scenario filter
4
+ once at construction, and exposes per-(entity_class, parameter_name)
5
+ polars frames on demand. See ``audit/db_direct_param_map.md §5`` for
6
+ the binding spec.
7
+
8
+ Lazy-evaluation pattern
9
+ -----------------------
10
+ The constructor materialises three eager caches: the per-class entity
11
+ universe, the per-(class, parameter) row list, and the per-(class,
12
+ parameter) default. Subsequent :meth:`parameter` calls walk the
13
+ cached rows once, build the result with :class:`polars.LazyFrame`
14
+ chains (default broadcast / left-join / cast), and ``.collect()`` once
15
+ at the boundary. Callers receive eager DataFrames (the API contract);
16
+ internal composition stays lazy so polars can fuse / optimise the
17
+ chain.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from collections import defaultdict
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+ import polars as pl
26
+
27
+ from flextool.spinedb_backend._axis_enums import (
28
+ AxisContract,
29
+ FlexDataIntegrityError,
30
+ cast_against_contract,
31
+ load_axis_contract,
32
+ )
33
+
34
+ # Late imports of spinedb_api at construction time keep the import
35
+ # graph free of an unconditional dependency for users who only consume
36
+ # CSVs.
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Helpers
41
+
42
+
43
+ def _coerce_index(idx: Any) -> Any:
44
+ """Coerce a single index into a Python type polars likes.
45
+
46
+ ``Map.indexes`` returns ``numpy.str_`` (and equivalent numpy
47
+ scalar types); polars happily accepts these but constructing a
48
+ :class:`pl.DataFrame` mixed-source from them is more reliable when
49
+ they're plain Python. Datetimes (``numpy.datetime64`` from
50
+ :class:`TimeSeries`) get returned to polars as-is — polars converts
51
+ them on construction.
52
+ """
53
+ if isinstance(idx, np.str_):
54
+ return str(idx)
55
+ if isinstance(idx, np.generic):
56
+ # numpy.float64 / int64 / bool_ → Python scalar.
57
+ return idx.item()
58
+ return idx
59
+
60
+
61
+ def _coerce_value(v: Any) -> Any:
62
+ """Coerce a leaf value into a Python type with reasonable polars
63
+ dtype inference. Strings stay strings; numeric numpy scalars become
64
+ Python floats / ints; booleans stay booleans; everything else
65
+ passes through (polars will reject blob-like values via the schema
66
+ check downstream).
67
+ """
68
+ if isinstance(v, np.str_):
69
+ return str(v)
70
+ if isinstance(v, np.generic):
71
+ return v.item()
72
+ return v
73
+
74
+
75
+ # Default canonical column names per spec §4.2. Used when a `Map.index_name`
76
+ # is empty / generic. Probed in order of nesting depth so the topmost
77
+ # (period) gets the first slot.
78
+ _DEFAULT_INDEX_NAMES = ("period", "tier", "branch", "t", "sub_index")
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # SpineDbReader
83
+
84
+
85
+ class SpineDbReader:
86
+ """Read flextool input data directly from a SpineDB scenario.
87
+
88
+ Parameters
89
+ ----------
90
+ db_url : str
91
+ Spine sqlite URL. Bare paths are auto-prefixed with
92
+ ``sqlite:///``.
93
+ scenario : str
94
+ Scenario name to apply via
95
+ :func:`spinedb_api.filters.scenario_filter.apply_scenario_filter_to_subqueries`.
96
+
97
+ The constructor opens the DB, applies the filter, and pre-builds
98
+ three caches:
99
+
100
+ * ``_entities[class_id]``: list of (entity_id, name, element_name_list).
101
+ * ``_param_rows[(class_id, pdef_id)]``: list of (entity_id, value, type).
102
+ * ``_param_defs[(class_id, pdef_name)]``: pdef row + parsed default.
103
+
104
+ The DB is closed at construction time after the caches are built —
105
+ no DB handle is held across calls.
106
+ """
107
+
108
+ # ------------------------------------------------------------------
109
+ # Construction
110
+
111
+ def __init__(
112
+ self,
113
+ db_url: str,
114
+ scenario: str,
115
+ *,
116
+ axis_enums: dict[str, pl.Enum] | None = None,
117
+ contract: AxisContract | None = None,
118
+ ):
119
+ url = str(db_url)
120
+ if "://" not in url:
121
+ url = f"sqlite:///{url}"
122
+ self._db_url = url
123
+ self._scenario = scenario
124
+ # Phase 2 cast-on-emit: when ``axis_enums`` is supplied, every
125
+ # frame returned by entities / parameter / parameter_explicit is
126
+ # cast against the contract before return. ``None`` keeps the
127
+ # pre-Phase-2 behaviour (Utf8 dim columns) so callers that
128
+ # haven't opted in yet see no change.
129
+ self._axis_enums = axis_enums
130
+ # The contract is needed unconditionally now: even when callers
131
+ # don't opt into the Phase-2 axis-enum cast on dim columns, the
132
+ # value-column dtype contract (parameter_value_dtypes) still
133
+ # applies so Map leaves can be normalised to Float64 / Utf8 /
134
+ # Boolean regardless of how the cells were authored in Spine.
135
+ if contract is None:
136
+ contract = load_axis_contract()
137
+ self._contract = contract
138
+
139
+ # Build caches once.
140
+ from spinedb_api import DatabaseMapping, from_database
141
+ from spinedb_api.filters.scenario_filter import (
142
+ apply_scenario_filter_to_subqueries,
143
+ )
144
+
145
+ with DatabaseMapping(url) as db:
146
+ apply_scenario_filter_to_subqueries(db, scenario)
147
+
148
+ # Class id ↔ name map.
149
+ self._class_id_to_name: dict[int, str] = {}
150
+ self._class_name_to_id: dict[str, int] = {}
151
+ for c in db.query(db.entity_class_sq).all():
152
+ self._class_id_to_name[c.id] = c.name
153
+ self._class_name_to_id[c.name] = c.id
154
+
155
+ # Class dim names (from wide subquery). None for 0-dim.
156
+ self._class_dim_names: dict[int, list[str] | None] = {}
157
+ for c in db.query(db.wide_entity_class_sq).all():
158
+ if c.dimension_name_list:
159
+ self._class_dim_names[c.id] = c.dimension_name_list.split(",")
160
+ else:
161
+ self._class_dim_names[c.id] = None
162
+
163
+ # Per-class entities (via wide_entity_sq for element_name_list).
164
+ self._entities_by_class: dict[int, list[tuple[int, str, list[str] | None]]] = (
165
+ defaultdict(list)
166
+ )
167
+ for e in db.query(db.wide_entity_sq).all():
168
+ if e.element_name_list:
169
+ elements = e.element_name_list.split(",")
170
+ else:
171
+ elements = None
172
+ self._entities_by_class[e.class_id].append(
173
+ (e.id, e.name, elements)
174
+ )
175
+
176
+ # Parameter definitions (cached eagerly, parsed defaults).
177
+ self._pdef_by_class_name: dict[tuple[int, str], dict] = {}
178
+ for p in db.query(db.wide_parameter_definition_sq).all():
179
+ default_val = from_database(p.default_value, p.default_type)
180
+ self._pdef_by_class_name[(p.entity_class_id, p.name)] = {
181
+ "id": p.id,
182
+ "name": p.name,
183
+ "default_value": default_val,
184
+ "default_type": p.default_type,
185
+ "value_list_id": p.parameter_value_list_id,
186
+ }
187
+
188
+ # Per-(class_id, pdef_id) value rows. We load eagerly,
189
+ # parsing values via ``from_database`` once, so per-call
190
+ # ``parameter()`` is pure polars assembly.
191
+ self._param_rows: dict[tuple[int, int], list[tuple[int, Any]]] = (
192
+ defaultdict(list)
193
+ )
194
+ for r in db.query(db.parameter_value_sq).all():
195
+ v = from_database(r.value, r.type)
196
+ self._param_rows[(r.entity_class_id, r.parameter_definition_id)] \
197
+ .append((r.entity_id, v))
198
+
199
+ # entity_id → (class_id, name) for joining.
200
+ self._entity_by_id: dict[int, tuple[int, str]] = {}
201
+ for cls_id, ents in self._entities_by_class.items():
202
+ for eid, name, _ in ents:
203
+ self._entity_by_id[eid] = (cls_id, name)
204
+
205
+ # v56 Batch F — drop is_enabled="no" entities of the three
206
+ # gated classes from the caches. Mirrors
207
+ # :meth:`flextool.spinedb_backend._backend.SpineDBBackend.
208
+ # _disabled_entity_ids` so the cascade direct-param path
209
+ # (``InputSource.entities`` / ``InputSource.parameter`` via
210
+ # this reader) sees the same effective entity set as the
211
+ # input_derivation Backend.
212
+ _IS_ENABLED_CLASSES = (
213
+ "constraint",
214
+ "reserve__upDown__unit__node",
215
+ "reserve__upDown__connection__node",
216
+ )
217
+ for gated_cls_name in _IS_ENABLED_CLASSES:
218
+ gated_cls_id = self._class_name_to_id.get(gated_cls_name)
219
+ if gated_cls_id is None:
220
+ continue
221
+ is_enabled_pdef = self._pdef_by_class_name.get(
222
+ (gated_cls_id, "is_enabled"),
223
+ )
224
+ if is_enabled_pdef is None:
225
+ continue
226
+ disabled_ids: set[int] = set()
227
+ for eid, v in self._param_rows.get(
228
+ (gated_cls_id, is_enabled_pdef["id"]), [],
229
+ ):
230
+ if v == "no":
231
+ disabled_ids.add(eid)
232
+ if not disabled_ids:
233
+ continue
234
+ # Drop disabled entities from the per-class entity list.
235
+ self._entities_by_class[gated_cls_id] = [
236
+ row for row in self._entities_by_class[gated_cls_id]
237
+ if row[0] not in disabled_ids
238
+ ]
239
+ # Drop disabled entities from every parameter_value row
240
+ # cache of the same class. Walk every (cls_id, pdef_id)
241
+ # key whose cls_id matches; rebuild the row list without
242
+ # the disabled entries.
243
+ for (cls_id_key, pdef_id_key), rows in list(
244
+ self._param_rows.items(),
245
+ ):
246
+ if cls_id_key != gated_cls_id:
247
+ continue
248
+ filtered = [
249
+ r for r in rows if r[0] not in disabled_ids
250
+ ]
251
+ if filtered:
252
+ self._param_rows[(cls_id_key, pdef_id_key)] = (
253
+ filtered
254
+ )
255
+ else:
256
+ # Empty buckets behave the same as missing
257
+ # buckets for ``parameter()`` (it uses
258
+ # ``.get(..., [])``); keep the cache compact.
259
+ del self._param_rows[(cls_id_key, pdef_id_key)]
260
+ # Drop disabled entries from the entity_id → (cls, name)
261
+ # map so downstream joiners can't accidentally re-emit
262
+ # them.
263
+ for did in disabled_ids:
264
+ self._entity_by_id.pop(did, None)
265
+
266
+ # ------------------------------------------------------------------
267
+ # Public diagnostics
268
+
269
+ @property
270
+ def db_url(self) -> str:
271
+ return self._db_url
272
+
273
+ @property
274
+ def scenario(self) -> str:
275
+ return self._scenario
276
+
277
+ def __repr__(self) -> str:
278
+ return f"SpineDbReader(db_url={self._db_url!r}, scenario={self._scenario!r})"
279
+
280
+ # ------------------------------------------------------------------
281
+ # Entity columns: ``[name]`` for 0-dim classes; one column per dim
282
+ # (named after the dim class) for n-dim relationships. Repeated
283
+ # dim classes get a 1-based suffix.
284
+
285
+ def _entity_columns(self, class_id: int) -> list[str]:
286
+ """Resolve the column names for the given class's entity frame.
287
+
288
+ For 0-dim classes returns ``["name"]``. For n-dim relationships
289
+ returns the dim-class names, with repeats disambiguated by a
290
+ ``_N`` suffix (e.g. ``connection__node__node`` →
291
+ ``["connection", "node_1", "node_2"]``).
292
+ """
293
+ dims = self._class_dim_names.get(class_id)
294
+ if dims is None:
295
+ return ["name"]
296
+ # Disambiguate duplicates.
297
+ seen: dict[str, int] = defaultdict(int)
298
+ cols: list[str] = []
299
+ # First pass: count occurrences.
300
+ counts: dict[str, int] = defaultdict(int)
301
+ for d in dims:
302
+ counts[d] += 1
303
+ # Second pass: emit, suffixing only those with multiplicity > 1.
304
+ for d in dims:
305
+ if counts[d] > 1:
306
+ seen[d] += 1
307
+ cols.append(f"{d}_{seen[d]}")
308
+ else:
309
+ cols.append(d)
310
+ return cols
311
+
312
+ # ------------------------------------------------------------------
313
+ # InputSource Protocol — entities
314
+
315
+ def entities(self, entity_class: str) -> pl.DataFrame:
316
+ cls_id = self._class_name_to_id.get(entity_class)
317
+ if cls_id is None:
318
+ # Unknown class — empty frame with a sensible schema.
319
+ return pl.DataFrame(schema={"name": pl.Utf8})
320
+ cols = self._entity_columns(cls_id)
321
+ rows = self._entities_by_class.get(cls_id, [])
322
+ if not rows:
323
+ return pl.DataFrame(schema={c: pl.Utf8 for c in cols})
324
+ if len(cols) == 1:
325
+ frame = (
326
+ pl.DataFrame({cols[0]: [name for _, name, _ in rows]},
327
+ schema={cols[0]: pl.Utf8})
328
+ .sort(cols)
329
+ )
330
+ return self._maybe_cast_frame(
331
+ frame,
332
+ entity_class=entity_class,
333
+ parameter_name=None,
334
+ )
335
+ # Multi-dim: split element_name_list into N columns.
336
+ data: dict[str, list[str]] = {c: [] for c in cols}
337
+ for _, _ent_name, elements in rows:
338
+ if elements is None or len(elements) != len(cols):
339
+ # Defensive: skip malformed rows rather than crash.
340
+ continue
341
+ for c, v in zip(cols, elements):
342
+ data[c].append(v)
343
+ schema = {c: pl.Utf8 for c in cols}
344
+ frame = pl.DataFrame(data, schema=schema).sort(cols)
345
+ return self._maybe_cast_frame(
346
+ frame,
347
+ entity_class=entity_class,
348
+ parameter_name=None,
349
+ )
350
+
351
+ # ------------------------------------------------------------------
352
+ # InputSource Protocol — parameter
353
+
354
+ def parameter_default(self, entity_class: str, parameter_name: str) -> Any:
355
+ cls_id = self._class_name_to_id.get(entity_class)
356
+ if cls_id is None:
357
+ return None
358
+ pdef = self._pdef_by_class_name.get((cls_id, parameter_name))
359
+ if pdef is None:
360
+ return None
361
+ return pdef["default_value"]
362
+
363
+ # ------------------------------------------------------------------
364
+ # Δ.17c — raw per-level index_name labels for a parameter.
365
+ #
366
+ # Used by :func:`flextool.engine_polars._param_shapes.resolve_param_shape`
367
+ # to validate the parameter's shape against an explicit allow-list
368
+ # (per the user's directive "read from database the dimensionality
369
+ # of the parameter" + "read the dimension index label from the
370
+ # database").
371
+ #
372
+ # Returns the list of raw ``Map.index_name`` labels per nesting
373
+ # depth: empty list for scalars; one entry per Map level for
374
+ # n-dim Maps. Labels are returned exactly as the DB authored them
375
+ # (no normalisation, no canonical default substitution) so the
376
+ # caller can detect "wrong index_name" and raise loudly.
377
+ #
378
+ # ``TimeSeries`` / ``Array`` collapse to a single canonical label
379
+ # (``"time"`` / ``""`` respectively) — neither is valid for the
380
+ # registry-routed parameters today, but rejecting them with an
381
+ # accurate label is better than silently treating them as a Map.
382
+ def parameter_shape_info(self, entity_class: str,
383
+ parameter_name: str) -> "list[str | None]":
384
+ cls_id = self._class_name_to_id.get(entity_class)
385
+ if cls_id is None:
386
+ raise KeyError(f"unknown entity_class {entity_class!r}")
387
+ pdef = self._pdef_by_class_name.get((cls_id, parameter_name))
388
+ if pdef is None:
389
+ raise KeyError(
390
+ f"unknown parameter ({entity_class!r}, {parameter_name!r})"
391
+ )
392
+ rows = self._param_rows.get((cls_id, pdef["id"]), [])
393
+ # Probe the deepest-nested row to capture the widest schema.
394
+ # Spine schema invariant: all rows for a parameter under a
395
+ # scenario share the same shape, but we're defensive in case
396
+ # a fixture mixes shapes (the resolver caller will raise on
397
+ # ambiguity downstream).
398
+ deepest: list[str | None] = []
399
+ for _eid, v in rows:
400
+ cand = self._index_name_path(v)
401
+ if len(cand) > len(deepest):
402
+ deepest = cand
403
+ if deepest:
404
+ return deepest
405
+ # No explicit rows — inspect the parameter's default_value (the
406
+ # schema default may itself be a Map). Otherwise treat as
407
+ # scalar (depth 0).
408
+ default = pdef["default_value"]
409
+ if default is not None:
410
+ return self._index_name_path(default)
411
+ return []
412
+
413
+ @staticmethod
414
+ def _index_name_path(v: Any) -> "list[str | None]":
415
+ """Walk *v*'s nesting and return raw ``Map.index_name`` labels
416
+ per depth level. Used by :meth:`parameter_shape_info`.
417
+
418
+ Differs from :meth:`_discover_index_cols` in that it returns
419
+ the raw DB labels (``None`` / empty when unset) instead of the
420
+ canonical defaults — that's the whole point of the Δ.17c
421
+ resolver path.
422
+ """
423
+ from spinedb_api.parameter_value import (
424
+ Map, TimeSeries, Array,
425
+ )
426
+ out: list[str | None] = []
427
+ cur = v
428
+ while True:
429
+ if isinstance(cur, Map):
430
+ out.append(cur.index_name if cur.index_name else None)
431
+ if len(cur.values) == 0:
432
+ break
433
+ cur = cur.values[0]
434
+ continue
435
+ if isinstance(cur, TimeSeries):
436
+ out.append("time")
437
+ break
438
+ if isinstance(cur, Array):
439
+ out.append("")
440
+ break
441
+ break
442
+ return out
443
+
444
+ def parameter_explicit(self, entity_class: str,
445
+ parameter_name: str) -> pl.DataFrame:
446
+ """Like :meth:`parameter` but suppresses default-broadcast rows.
447
+
448
+ Returns ONLY entities that have a row in the parameter_value
449
+ table for the active scenario — i.e. an explicit override. The
450
+ Spine schema default never appears.
451
+
452
+ Useful for helpers that mirror flextool's preprocessing
453
+ ``p_unit.get(name, None)`` semantic, where "absent" must remain
454
+ distinguishable from "present with the default value".
455
+ """
456
+ cls_id = self._class_name_to_id.get(entity_class)
457
+ if cls_id is None:
458
+ raise KeyError(f"unknown entity_class {entity_class!r}")
459
+ pdef = self._pdef_by_class_name.get((cls_id, parameter_name))
460
+ if pdef is None:
461
+ raise KeyError(
462
+ f"unknown parameter ({entity_class!r}, {parameter_name!r})"
463
+ )
464
+ ent_cols = self._entity_columns(cls_id)
465
+ rows = self._param_rows.get((cls_id, pdef["id"]), [])
466
+ columns, index_cols, leaf_dtype = self._unroll_rows(
467
+ rows, ent_cols, parameter_name, entity_class=entity_class,
468
+ )
469
+ if not columns or not columns["value"]:
470
+ schema_in = {c: pl.Utf8 for c in ent_cols}
471
+ for ic in index_cols:
472
+ schema_in[ic] = pl.Utf8
473
+ schema_in["value"] = leaf_dtype or pl.Float64
474
+ return self._maybe_cast_frame(
475
+ pl.DataFrame(schema=schema_in),
476
+ entity_class=entity_class,
477
+ parameter_name=parameter_name,
478
+ )
479
+ overrides = {"value": leaf_dtype} if leaf_dtype is not None else None
480
+ # ``strict=False`` preserves the tolerance of the pre-columnar
481
+ # ``pl.DataFrame(list_of_dicts)`` constructor for heterogeneous
482
+ # Map-index columns (e.g. solve.invest_periods with mixed
483
+ # String / Int64 indexes across entities).
484
+ frame = self._finalize(
485
+ pl.DataFrame(columns, schema_overrides=overrides, strict=False).lazy(),
486
+ ent_cols + index_cols,
487
+ )
488
+ return self._maybe_cast_frame(
489
+ frame,
490
+ entity_class=entity_class,
491
+ parameter_name=parameter_name,
492
+ )
493
+
494
+ def parameter(self, entity_class: str, parameter_name: str) -> pl.DataFrame:
495
+ cls_id = self._class_name_to_id.get(entity_class)
496
+ if cls_id is None:
497
+ raise KeyError(f"unknown entity_class {entity_class!r}")
498
+ pdef = self._pdef_by_class_name.get((cls_id, parameter_name))
499
+ if pdef is None:
500
+ raise KeyError(
501
+ f"unknown parameter ({entity_class!r}, {parameter_name!r})"
502
+ )
503
+
504
+ ent_cols = self._entity_columns(cls_id)
505
+ rows = self._param_rows.get((cls_id, pdef["id"]), [])
506
+ default = pdef["default_value"]
507
+
508
+ # Step 1 — unroll each row into per-column lists. The shape
509
+ # depends on the value's runtime type (scalar / Map / TimeSeries
510
+ # / Array). We don't know up-front whether the parameter is
511
+ # "scalar across all rows"; we infer per-row.
512
+ columns, index_cols, leaf_dtype = self._unroll_rows(
513
+ rows, ent_cols, parameter_name, entity_class=entity_class,
514
+ )
515
+
516
+ # Step 2 — assemble the per-parameter LazyFrame. Empty rows
517
+ # collapse to a 0-row frame with the correct schema.
518
+ if not columns or not columns["value"]:
519
+ schema_in = {c: pl.Utf8 for c in ent_cols}
520
+ for ic in index_cols:
521
+ schema_in[ic] = pl.Utf8
522
+ schema_in["value"] = leaf_dtype or pl.Float64
523
+ v_lf = pl.DataFrame(schema=schema_in).lazy()
524
+ else:
525
+ overrides = {"value": leaf_dtype} if leaf_dtype is not None else None
526
+ # ``strict=False`` preserves pre-columnar tolerance of mixed
527
+ # Map-index types (see _spinedb_reader.py:415).
528
+ v_lf = pl.DataFrame(
529
+ columns, schema_overrides=overrides, strict=False,
530
+ ).lazy()
531
+
532
+ # Step 3 — apply the §4.5 default policy lazily.
533
+ if default is None:
534
+ # None-skip: return rows as-is.
535
+ frame = self._finalize(v_lf, ent_cols + index_cols)
536
+ return self._maybe_cast_frame(
537
+ frame,
538
+ entity_class=entity_class,
539
+ parameter_name=parameter_name,
540
+ )
541
+
542
+ if not index_cols:
543
+ # Scalar-default + scalar-parameter → broadcast. Build the
544
+ # entities frame from the raw cache (not via self.entities())
545
+ # so the join keys stay Utf8 — casting happens once at the
546
+ # end against the joined frame.
547
+ E = self._entities_frame_raw(cls_id, ent_cols).lazy()
548
+ joined = (E.join(v_lf, on=ent_cols, how="left")
549
+ .with_columns(pl.col("value").fill_null(default)))
550
+ frame = self._finalize(joined, ent_cols)
551
+ return self._maybe_cast_frame(
552
+ frame,
553
+ entity_class=entity_class,
554
+ parameter_name=parameter_name,
555
+ )
556
+
557
+ # Scalar-default + indexed-parameter → return overrides only;
558
+ # the default is consumed via parameter_default() upstream.
559
+ frame = self._finalize(v_lf, ent_cols + index_cols)
560
+ return self._maybe_cast_frame(
561
+ frame,
562
+ entity_class=entity_class,
563
+ parameter_name=parameter_name,
564
+ )
565
+
566
+ def _entities_frame_raw(
567
+ self, cls_id: int, cols: list[str],
568
+ ) -> pl.DataFrame:
569
+ """Internal — build the raw Utf8 entities frame for *cls_id*
570
+ without going through :meth:`entities` (which would cast).
571
+
572
+ Used by :meth:`parameter` to keep the join keys Utf8 so the
573
+ join is a String/String op (polars 1.40 won't auto-coerce
574
+ Utf8↔Enum on join keys); the final frame is cast once before
575
+ return.
576
+ """
577
+ rows = self._entities_by_class.get(cls_id, [])
578
+ if not rows:
579
+ return pl.DataFrame(schema={c: pl.Utf8 for c in cols})
580
+ if len(cols) == 1:
581
+ return (
582
+ pl.DataFrame({cols[0]: [name for _, name, _ in rows]},
583
+ schema={cols[0]: pl.Utf8})
584
+ .sort(cols)
585
+ )
586
+ data: dict[str, list[str]] = {c: [] for c in cols}
587
+ for _, _ent_name, elements in rows:
588
+ if elements is None or len(elements) != len(cols):
589
+ continue
590
+ for c, v in zip(cols, elements):
591
+ data[c].append(v)
592
+ return pl.DataFrame(
593
+ data, schema={c: pl.Utf8 for c in cols},
594
+ ).sort(cols)
595
+
596
+ def _maybe_cast_frame(
597
+ self,
598
+ frame: pl.DataFrame,
599
+ *,
600
+ entity_class: str,
601
+ parameter_name: str | None,
602
+ ) -> pl.DataFrame:
603
+ """Cast *frame* against the configured axis enums + contract.
604
+
605
+ When the reader was constructed with ``axis_enums=None``
606
+ (default), the frame passes through unchanged — pre-Phase-2
607
+ back-compat. When ``axis_enums`` is non-None, every dim
608
+ column resolved via :meth:`AxisContract.column_to_axis` is
609
+ cast strictly; a vocabulary miss raises
610
+ :class:`FlexDataIntegrityError` with
611
+ ``(parameter, entity_class, scenario)`` threaded as the
612
+ origin breadcrumb.
613
+
614
+ Phase 4 — for 0-dim entity frames whose single column is
615
+ ``name``, cast that column against the axis whose source class
616
+ is ``entity_class`` (looked up in the contract). Without this
617
+ hook the ``name`` column remains Utf8 and every downstream
618
+ ``pl.col("name").alias("p")`` produces a Utf8 ``p`` that mixes
619
+ SchemaError-fully with the Enum-typed cascade.
620
+ """
621
+ if self._axis_enums is None:
622
+ return frame
623
+ # Pre-cast: handle entity-class element columns. For 0-dim
624
+ # classes the single column is ``name``; for n-dim relationships
625
+ # the columns are the dim-class names (``unit``, ``node``,
626
+ # ``node_1``, ``node_2``, ``connection`` …). Each such column
627
+ # carries entities of its dim class, so we cast against the
628
+ # axis whose source claims that class.
629
+ if self._contract is not None:
630
+ element_casts: list[pl.Expr] = []
631
+ for col in frame.columns:
632
+ if col == "name":
633
+ axis = self._axis_for_entity_class(entity_class)
634
+ elif col == "value":
635
+ # ``value`` is data, never a dim column.
636
+ axis = None
637
+ else:
638
+ # ``unit``, ``node``, ``connection``, … — element of
639
+ # a relationship class. Strip a trailing ``_N``
640
+ # disambiguator (``node_1`` / ``node_2`` → ``node``).
641
+ base = col.rsplit("_", 1)[0] if (
642
+ "_" in col and col.rsplit("_", 1)[1].isdigit()
643
+ ) else col
644
+ axis = self._axis_for_entity_class(base)
645
+ if axis is None:
646
+ continue
647
+ target = self._axis_enums.get(axis.name)
648
+ if target is None or frame.schema[col] == target:
649
+ continue
650
+ # Polars' numeric → Enum cast interprets the source value
651
+ # as a POSITIONAL INDEX into the enum's categories. When
652
+ # the source column is numeric (e.g. a Spine 1d_map with
653
+ # numeric keys whose level was labelled "constraint" /
654
+ # other axis-synonym name), casting to the matching axis
655
+ # enum would silently produce category-by-position
656
+ # tokens. Skip the cast — numeric columns are never dim
657
+ # columns under this contract.
658
+ if frame.schema[col].is_numeric():
659
+ continue
660
+ element_casts.append(
661
+ pl.col(col).cast(target, strict=False)
662
+ )
663
+ if element_casts:
664
+ frame = frame.with_columns(element_casts)
665
+ # Scenario-trim Map-inner-key noise. When the reader is
666
+ # bound to a scenario filter, the axis enums (e.g. ``d``
667
+ # period) reflect only the scenario-active tokens. Map
668
+ # parameters may carry inner keys that exist in the broader
669
+ # DB but were authored outside the active scenario — those
670
+ # rows must be silently dropped so the strict cast below
671
+ # succeeds. Entity-reference columns
672
+ # (source_type=="entity_class"/"entity_class_union") are
673
+ # vocab-correct by construction (the backend pre-filtered
674
+ # them), so any out-of-vocab token there is a true typo and
675
+ # must still raise.
676
+ if self._scenario is not None and self._contract is not None:
677
+ drop_mask: pl.Series | None = None
678
+ for col in frame.columns:
679
+ axis = self._contract.column_to_axis(col)
680
+ if axis is None or axis.source_type != "parameter_keys":
681
+ continue
682
+ dtype = self._axis_enums.get(axis.name)
683
+ if dtype is None:
684
+ continue
685
+ if frame.schema[col].is_numeric():
686
+ continue
687
+ vocab = set(dtype.categories.to_list())
688
+ bad_rows = [
689
+ (v is not None and v != "" and v not in vocab)
690
+ for v in frame[col].to_list()
691
+ ]
692
+ if not any(bad_rows):
693
+ continue
694
+ col_mask = pl.Series(bad_rows, dtype=pl.Boolean)
695
+ drop_mask = (
696
+ col_mask if drop_mask is None else (drop_mask | col_mask)
697
+ )
698
+ if drop_mask is not None:
699
+ frame = frame.filter(~drop_mask)
700
+ origin = {
701
+ "parameter": parameter_name,
702
+ "entity": entity_class,
703
+ "scenario": self._scenario,
704
+ }
705
+ return cast_against_contract(
706
+ frame,
707
+ contract=self._contract,
708
+ axis_enums=self._axis_enums,
709
+ origin=origin,
710
+ )
711
+
712
+ def _axis_for_entity_class(self, entity_class: str):
713
+ """Return the contract axis whose source includes *entity_class*.
714
+
715
+ Walks the contract's axes for ``source_type == "entity_class"``
716
+ matching ``entity_class`` directly, or ``source_type ==
717
+ "entity_class_union"`` whose list includes ``entity_class``.
718
+ Returns the first matching :class:`AxisSpec`; ``None`` when no
719
+ axis claims this class (synthetic classes, methods, etc.).
720
+
721
+ When two axes claim the same class (e.g. ``node`` is sourced
722
+ by both ``n`` directly AND by ``e`` (the entity union)), the
723
+ single-class axis wins — its enum is narrower and more
724
+ precise than the union.
725
+ """
726
+ if self._contract is None:
727
+ return None
728
+ single: object | None = None
729
+ union: object | None = None
730
+ for axis in self._contract.axes:
731
+ if axis.source_type == "entity_class":
732
+ if axis.source == entity_class:
733
+ single = axis
734
+ elif axis.source_type == "entity_class_union":
735
+ if entity_class in (axis.source or []):
736
+ if union is None:
737
+ union = axis
738
+ return single if single is not None else union
739
+
740
+ # ------------------------------------------------------------------
741
+ # Unrolling the per-row blob into rectangular rows
742
+
743
+ def _unroll_rows(
744
+ self,
745
+ rows: list[tuple[int, Any]],
746
+ ent_cols: list[str],
747
+ parameter_name: str,
748
+ *,
749
+ entity_class: str,
750
+ ) -> tuple[dict[str, list], list[str], pl.DataType | None]:
751
+ """Walk each (entity_id, parsed_value) pair and emit a columnar
752
+ dict of per-column lists ready for ``pl.DataFrame``.
753
+
754
+ Columnar layout: ``columns[col_name]`` is a list, one entry per
755
+ scalar leaf. Every list has the same length. Building columns
756
+ directly is ~9x faster than the previous row-of-dicts pattern
757
+ when handed to ``pl.DataFrame`` (per the
758
+ ``arrow_value_direct_read_study.md`` benchmark). The recursion
759
+ uses a positional ``idx_path`` mutated in place via append /
760
+ pop, avoiding ``dict(base)`` copies at every Map node.
761
+
762
+ Returns the columns dict, the index column names (uniform
763
+ across rows for the parameter), and the inferred leaf dtype.
764
+ Empty input returns ``({}, [], None)``.
765
+
766
+ Dim columns leave as Utf8 lists; the caller's
767
+ ``_maybe_cast_frame`` (i.e.
768
+ :func:`flextool.spinedb_backend._axis_enums.cast_against_contract`)
769
+ applies the contract-axis Enum cast with its
770
+ (parameter, entity, scenario) error breadcrumbs. Don't wire
771
+ enum dtypes here — keep the construction step orthogonal to
772
+ the Phase 4 enum refactor for forward-compatibility with the
773
+ future Arrow-native read.
774
+ """
775
+ if not rows:
776
+ return {}, [], None
777
+
778
+ # Drop explicitly-null parameter values before shape discovery.
779
+ #
780
+ # A Spine ``null`` value (parsed to Python ``None`` by
781
+ # ``from_database``) carries no data — it is how a
782
+ # higher-priority alternative *clears* a value set in the base
783
+ # alternative (e.g. ``S4_Dry`` nulling out a base Map on
784
+ # ``node.constraint_invested_capacity_coeff``). Such a row must
785
+ # resolve to "unset" for that entity, NOT to a value-less scalar
786
+ # row. Left in place, a scalar-``None`` row overriding a base
787
+ # Map makes index discovery see no index column (a scalar
788
+ # discovers ``[]``); the frame then comes back as ``[name,
789
+ # value]`` and the downstream shape resolver (e.g.
790
+ # ``_resolve_constraint_index_col``) raises on the missing index
791
+ # axis. Routing whole-null values to absence instead makes them
792
+ # behave exactly like an unset parameter — already the common,
793
+ # well-handled case (most parameters are absent in most
794
+ # scenarios; every consumer guards ``df.height == 0``).
795
+ #
796
+ # SCOPE: this drops only a *whole* null value (``v is None``),
797
+ # never a null *leaf* inside a Map / TimeSeries / Array — those
798
+ # parse to container objects (not ``None``) and pass through
799
+ # untouched, so a Map's key-set / domain is never silently
800
+ # shrunk here. A null leaf inside a Map does not trigger the
801
+ # bug anyway: the sibling structure still yields the index
802
+ # column, so the shape resolver is satisfied. Dropping whole-
803
+ # null rows here (before discovery) is lossless for index
804
+ # discovery because a scalar ``None`` contributes no index
805
+ # column regardless (``_discover_index_cols(None) == []``).
806
+ rows = [(eid, v) for eid, v in rows if v is not None]
807
+ if not rows:
808
+ return {}, [], None
809
+
810
+ # Discover index columns from the widest-shaped row (spec
811
+ # §5.2.5). In practice flextool's params don't mix shapes
812
+ # within one scenario — but we're defensive.
813
+ index_cols: list[str] = []
814
+ for _eid, v in rows:
815
+ cand = self._discover_index_cols(v, parameter_name)
816
+ if len(cand) > len(index_cols):
817
+ index_cols = cand
818
+
819
+ # Pre-allocate one list per output column.
820
+ col_names = ent_cols + index_cols + ["value"]
821
+ columns: dict[str, list] = {name: [] for name in col_names}
822
+
823
+ # Walk each (entity_id, parsed_value) pair. ``idx_path`` is a
824
+ # positional list mirroring ``index_cols`` — mutated via
825
+ # append/pop inside the recursion, no per-node dict copy.
826
+ idx_path: list[Any] = []
827
+ for eid, v in rows:
828
+ cls_id, ent_name = self._entity_by_id[eid]
829
+ ent_values = self._entity_dim_values(cls_id, ent_name)
830
+ self._unroll_value(
831
+ v, index_cols, columns, ent_cols, ent_values, idx_path,
832
+ )
833
+
834
+ # Apply the contract dtype only to parameters that contain at
835
+ # least one Spine Map row. Map is the only shape where Spine
836
+ # silently drops type information (cells may be authored as
837
+ # the string "1" instead of the float 1.0), so this is the
838
+ # only path that needs the contract's lenient coerce.
839
+ #
840
+ # Other shapes bypass the contract:
841
+ #
842
+ # * Scalars (``str`` / ``float`` / ``bool``) — spinedb-api
843
+ # returns them with the correct native Python type. Forcing
844
+ # the contract default of ``Float64`` here would reject every
845
+ # scalar string parameter (e.g. ``node.node_type``).
846
+ # * ``TimeSeries`` — binary float arrays; no string-leaf risk.
847
+ # * ``Array`` — every FlexTool Array parameter holds Utf8
848
+ # leaves (period names, solve names, CLI args). None are
849
+ # numeric, so the contract default would reject them
850
+ # (e.g. ``model.solves`` = ``["y2020_2day_dispatch", ...]``).
851
+ from spinedb_api.parameter_value import Map
852
+ has_map = any(isinstance(v, Map) for _, v in rows)
853
+ if has_map:
854
+ leaf_dtype = self._contract.value_dtype_for(
855
+ entity_class, parameter_name,
856
+ )
857
+ self._coerce_value_column(
858
+ columns, ent_cols, index_cols,
859
+ leaf_dtype=leaf_dtype,
860
+ entity_class=entity_class,
861
+ parameter_name=parameter_name,
862
+ )
863
+ else:
864
+ leaf_dtype = None
865
+ sample = next(
866
+ (x for x in columns["value"] if x is not None), None,
867
+ )
868
+ if isinstance(sample, bool):
869
+ leaf_dtype = pl.Boolean
870
+ elif isinstance(sample, (int, float)):
871
+ leaf_dtype = pl.Float64
872
+ elif isinstance(sample, str):
873
+ leaf_dtype = pl.Utf8
874
+
875
+ return columns, index_cols, leaf_dtype
876
+
877
+ def _coerce_value_column(
878
+ self,
879
+ columns: dict[str, list],
880
+ ent_cols: list[str],
881
+ index_cols: list[str],
882
+ *,
883
+ leaf_dtype: pl.DataType,
884
+ entity_class: str,
885
+ parameter_name: str,
886
+ ) -> None:
887
+ """In-place coerce ``columns["value"]`` to the contract dtype.
888
+
889
+ Spine does not enforce types inside Map values, so the same
890
+ parameter may carry float ``1.0`` and string ``"1"`` in
891
+ different cells. ``pl.DataFrame(..., schema_overrides=...)``
892
+ with a strict cast would either reject the column outright or
893
+ silently null the str leaves under ``strict=False``. Neither
894
+ is what we want — we want lenient parsing (Spine reality)
895
+ plus a loud, breadcrumb-rich error for genuinely unparseable
896
+ cells.
897
+
898
+ For ``Float64``: replace every ``str`` leaf with ``float(s)``
899
+ and raise :class:`FlexDataIntegrityError` on the first
900
+ unparseable token, with the offending entity name / map
901
+ index path / raw value embedded in the message.
902
+
903
+ For ``Boolean``: similar, with ``"yes"`` / ``"no"`` / ``"true"``
904
+ / ``"false"`` (case-insensitive) accepted.
905
+
906
+ For ``Utf8``: no-op.
907
+ """
908
+ if leaf_dtype == pl.Utf8:
909
+ return
910
+ vals = columns["value"]
911
+ if leaf_dtype == pl.Float64:
912
+ for i, v in enumerate(vals):
913
+ if v is None or isinstance(v, (int, float)) and not isinstance(v, bool):
914
+ continue
915
+ if isinstance(v, str):
916
+ s = v.strip()
917
+ try:
918
+ vals[i] = float(s)
919
+ continue
920
+ except ValueError:
921
+ pass
922
+ self._raise_value_dtype_error(
923
+ columns, ent_cols, index_cols,
924
+ bad_index=i,
925
+ entity_class=entity_class,
926
+ parameter_name=parameter_name,
927
+ expected="Float64 (number)",
928
+ raw_value=v,
929
+ )
930
+ elif leaf_dtype == pl.Boolean:
931
+ for i, v in enumerate(vals):
932
+ if v is None or isinstance(v, bool):
933
+ continue
934
+ if isinstance(v, str):
935
+ s = v.strip().lower()
936
+ if s in ("yes", "true", "1"):
937
+ vals[i] = True
938
+ continue
939
+ if s in ("no", "false", "0"):
940
+ vals[i] = False
941
+ continue
942
+ self._raise_value_dtype_error(
943
+ columns, ent_cols, index_cols,
944
+ bad_index=i,
945
+ entity_class=entity_class,
946
+ parameter_name=parameter_name,
947
+ expected="Boolean (yes/no/true/false)",
948
+ raw_value=v,
949
+ )
950
+
951
+ def _raise_value_dtype_error(
952
+ self,
953
+ columns: dict[str, list],
954
+ ent_cols: list[str],
955
+ index_cols: list[str],
956
+ *,
957
+ bad_index: int,
958
+ entity_class: str,
959
+ parameter_name: str,
960
+ expected: str,
961
+ raw_value: Any,
962
+ ) -> None:
963
+ """Raise :class:`FlexDataIntegrityError` describing one bad cell.
964
+
965
+ Reconstructs the entity name tuple and the map-index path from
966
+ the parallel ``columns`` lists so the message points at the
967
+ exact Spine cell to fix.
968
+ """
969
+ entity = tuple(columns[c][bad_index] for c in ent_cols)
970
+ idx_path = tuple(columns[c][bad_index] for c in index_cols)
971
+ idx_repr = " / ".join(str(x) for x in idx_path) if idx_path else "(scalar)"
972
+ raise FlexDataIntegrityError(
973
+ f"Parameter value type mismatch for "
974
+ f"{entity_class}.{parameter_name}: "
975
+ f"expected {expected} but cell "
976
+ f"entity={entity}, index_path={idx_repr} "
977
+ f"has value {raw_value!r} ({type(raw_value).__name__}). "
978
+ f"Edit the Spine database to store this cell as a number "
979
+ f"(not as text)."
980
+ )
981
+
982
+ def _entity_dim_values(self, class_id: int, ent_name: str) -> list[str]:
983
+ """Return the dim-element values for *ent_name* in class
984
+ *class_id*. For 0-dim classes returns ``[ent_name]``; for
985
+ n-relationships, returns the cached element list.
986
+ """
987
+ dims = self._class_dim_names.get(class_id)
988
+ if dims is None:
989
+ return [ent_name]
990
+ for eid, name, elements in self._entities_by_class[class_id]:
991
+ if name == ent_name:
992
+ if elements is None:
993
+ return [ent_name]
994
+ return elements
995
+ return [ent_name]
996
+
997
+ def _discover_index_cols(self, v: Any, parameter_name: str) -> list[str]:
998
+ """Walk the value's nesting structure and return the index
999
+ column names. Scalar values return ``[]``. Nested
1000
+ :class:`Map` / :class:`TimeSeries` / :class:`Array` return one
1001
+ name per level, falling back to canonical defaults from
1002
+ ``_DEFAULT_INDEX_NAMES`` when ``index_name`` is empty.
1003
+ """
1004
+ from spinedb_api.parameter_value import (
1005
+ Map, TimeSeries, Array,
1006
+ )
1007
+
1008
+ cols: list[str] = []
1009
+ depth = 0
1010
+ cur = v
1011
+ while True:
1012
+ if isinstance(cur, Map):
1013
+ name = cur.index_name or _DEFAULT_INDEX_NAMES[
1014
+ depth if depth < len(_DEFAULT_INDEX_NAMES) else -1
1015
+ ]
1016
+ # Map's "time" index is conventionally 't' in flextool.
1017
+ if name == "time":
1018
+ name = "t"
1019
+ # Disambiguate when this level's index_name collides
1020
+ # with one already produced at an outer level — e.g.
1021
+ # ``examples.sqlite::invest_5weeks.invest_periods`` is
1022
+ # a 2D Map where BOTH levels carry ``index_name='x'``.
1023
+ # Without disambiguation the outer (anchor) and inner
1024
+ # (period) indexes both land in a single ``x`` column
1025
+ # and the outer is silently overwritten (deepest-wins
1026
+ # in _emit_leaf). That loses the anchor → window
1027
+ # mapping the synthetic per-sub-solve cascade needs
1028
+ # (see ``_derived_params._solve_periods``). Append a
1029
+ # depth suffix to keep both levels addressable.
1030
+ if name in cols:
1031
+ name = f"{name}_{depth + 1}"
1032
+ cols.append(name)
1033
+ depth += 1
1034
+ # Probe the first child value to continue.
1035
+ if len(cur.values) == 0:
1036
+ break
1037
+ cur = cur.values[0]
1038
+ continue
1039
+ if isinstance(cur, TimeSeries):
1040
+ cols.append("t")
1041
+ break
1042
+ if isinstance(cur, Array):
1043
+ # Use a non-axis name so cast_against_contract leaves
1044
+ # this column alone. "i" collides with the canonical
1045
+ # tier_index axis ("i") whose vocabulary comes from
1046
+ # commodity.price_ladder_*; Array indices are
1047
+ # position-only and have no semantic axis.
1048
+ cols.append("_array_index")
1049
+ break
1050
+ break
1051
+ return cols
1052
+
1053
+ def _unroll_value(self, v: Any, index_cols: list[str],
1054
+ columns: dict[str, list],
1055
+ ent_cols: list[str],
1056
+ ent_values: list[str],
1057
+ idx_path: list[Any]) -> None:
1058
+ """Recursively unroll *v* into per-column lists in ``columns``.
1059
+
1060
+ ``idx_path`` is a positional list parallel to ``index_cols`` —
1061
+ mutated via append/pop as we descend. Each scalar leaf
1062
+ triggers an ``_emit_leaf`` that appends entity values + the
1063
+ current ``idx_path`` (padded with ``None`` for any
1064
+ unreached trailing index columns) + the coerced leaf into the
1065
+ relevant column lists.
1066
+ """
1067
+ from spinedb_api.parameter_value import (
1068
+ Map, TimeSeries, Array,
1069
+ )
1070
+
1071
+ # Scalar leaf.
1072
+ if not isinstance(v, (Map, TimeSeries, Array)):
1073
+ self._emit_leaf(
1074
+ columns, ent_cols, ent_values, idx_path, index_cols,
1075
+ _coerce_value(v),
1076
+ )
1077
+ return
1078
+
1079
+ # Map / TimeSeries / Array all recurse identically once we know
1080
+ # which index column applies at the current depth. Map's
1081
+ # ``index_name`` is already resolved into ``index_cols`` at
1082
+ # discovery time; TimeSeries / Array fall through to the
1083
+ # depth-indexed slot (or ``i`` if we've outrun the discovered
1084
+ # index_cols, e.g. a shape-mixed parameter).
1085
+ len(idx_path)
1086
+ if isinstance(v, Map):
1087
+ for idx, child in zip(v.indexes, v.values):
1088
+ idx_path.append(_coerce_index(idx))
1089
+ self._unroll_value(
1090
+ child, index_cols, columns, ent_cols, ent_values, idx_path,
1091
+ )
1092
+ idx_path.pop()
1093
+ return
1094
+
1095
+ if isinstance(v, TimeSeries):
1096
+ for idx, val in zip(v.indexes, v.values):
1097
+ idx_path.append(_coerce_index(idx))
1098
+ self._emit_leaf(
1099
+ columns, ent_cols, ent_values, idx_path, index_cols,
1100
+ _coerce_value(val),
1101
+ )
1102
+ idx_path.pop()
1103
+ return
1104
+
1105
+ if isinstance(v, Array):
1106
+ for i, val in enumerate(v.values):
1107
+ idx_path.append(i)
1108
+ self._emit_leaf(
1109
+ columns, ent_cols, ent_values, idx_path, index_cols,
1110
+ _coerce_value(val),
1111
+ )
1112
+ idx_path.pop()
1113
+ return
1114
+
1115
+ def _emit_leaf(self, columns: dict[str, list],
1116
+ ent_cols: list[str],
1117
+ ent_values: list[str],
1118
+ idx_path: list[Any],
1119
+ index_cols: list[str],
1120
+ value: Any) -> None:
1121
+ """Append one scalar leaf row across all per-column lists.
1122
+
1123
+ Pads trailing ``index_cols`` (those beyond ``len(idx_path)``)
1124
+ with ``None`` so every column list ends up at the same length
1125
+ even when a parameter mixes shallow and deep shapes within a
1126
+ scenario.
1127
+
1128
+ Duplicate names in ``index_cols`` (e.g. a nested Map where
1129
+ every level shares the same ``index_name='x'``) collapse to a
1130
+ single column via the polars dict-of-lists deduplication. In
1131
+ that case we use a *last-wins* policy per leaf so the deepest
1132
+ Map level's index value survives — matching the pre-columnar
1133
+ ``pl.DataFrame(list_of_dicts)`` behaviour where each
1134
+ ``child_base[col_name] = ...`` overwrote the outer entry.
1135
+ """
1136
+ for col, val in zip(ent_cols, ent_values):
1137
+ columns[col].append(val)
1138
+ # Resolve duplicate column names by keeping the last index_path
1139
+ # value per name (deepest-wins; pad with None when idx_path is
1140
+ # shorter than index_cols).
1141
+ n_path = len(idx_path)
1142
+ per_col_value: dict[str, Any] = {}
1143
+ for i, col in enumerate(index_cols):
1144
+ if i < n_path:
1145
+ per_col_value[col] = idx_path[i]
1146
+ else:
1147
+ per_col_value.setdefault(col, None)
1148
+ for col, v in per_col_value.items():
1149
+ columns[col].append(v)
1150
+ columns["value"].append(value)
1151
+
1152
+ # ------------------------------------------------------------------
1153
+ # Materialisation
1154
+
1155
+ def _finalize(self, lf: pl.LazyFrame, sort_cols: list[str]) -> pl.DataFrame:
1156
+ """Sort by ``sort_cols`` (deterministic row order — §4.3) and
1157
+ collect. Single materialisation point per :meth:`parameter`
1158
+ call so the polars optimiser can fuse the chain.
1159
+ """
1160
+ # Only sort by columns actually present (defensive).
1161
+ cols_present = lf.collect_schema().names()
1162
+ keep = [c for c in sort_cols if c in cols_present]
1163
+ if keep:
1164
+ lf = lf.sort(keep)
1165
+ return lf.collect()