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,1119 @@
1
+ """Build axis enums from the canonical contract + a SpineDBBackend.
2
+
3
+ This module is the EAV → axis-vocabulary bridge for the pl.Enum dtype
4
+ refactor. It reads the contract at schemas/flextool_axis_contract.json,
5
+ queries SpineDBBackend for entity-class vocabularies and parameter-map
6
+ keys, and emits a dict[str, pl.Enum] keyed by axis name.
7
+
8
+ The cast helper :func:`cast_against_contract` validates frames at the
9
+ Backend/SpineDbReader emit boundaries (see Phase 2). On vocabulary
10
+ miss, it raises :class:`FlexDataIntegrityError` with a beginner-friendly
11
+ 4-paragraph message that names the offending token, where it appeared,
12
+ and a short list of next-step suggestions.
13
+
14
+ The contract is the authoritative source of axis names + sources;
15
+ this module never invents an axis. Synthetic-token allowlist entries
16
+ declared in the contract are folded into the relevant axis enum so
17
+ literal tokens introduced by cascade writers (e.g. ``eff`` / ``noEff``
18
+ for the branch axis, ``default`` for the block axis) round-trip
19
+ without integrity errors.
20
+
21
+ Public API
22
+ ----------
23
+
24
+ * :class:`AxisSpec` — one parsed axis row.
25
+ * :class:`AxisContract` — full parsed contract.
26
+ * :func:`load_axis_contract` — parse the JSON file.
27
+ * :func:`build_axis_enums` — emit ``{axis_name: pl.Enum}``.
28
+ * :func:`cast_against_contract` — cast a frame's dim columns; raise
29
+ :class:`FlexDataIntegrityError` on vocabulary miss.
30
+ * :class:`FlexDataIntegrityError` — beginner-friendly cast failure.
31
+
32
+ This is Phase 1 of the pl.Enum dtype refactor. Phase 2 wires the
33
+ cast helper into the Backend's emit boundary; Phase 4 activates it
34
+ across the cascade. See ``specs/enum_dtype_refactor_plan.md`` for the
35
+ full plan.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Any, Iterable, Sequence
43
+
44
+ import polars as pl
45
+
46
+
47
+ _DTYPE_BY_NAME: dict[str, pl.DataType] = {
48
+ "Float64": pl.Float64,
49
+ "Utf8": pl.Utf8,
50
+ "Boolean": pl.Boolean,
51
+ }
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Contract path resolution
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def _default_contract_path() -> Path:
60
+ """Return the bundled ``flextool/schemas/flextool_axis_contract.json``.
61
+
62
+ Resolved via :mod:`importlib.resources` so the lookup works in both
63
+ editable and wheel installs.
64
+ """
65
+ from flextool._resources import package_data_path
66
+ return package_data_path("schemas/flextool_axis_contract.json")
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Dataclasses
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class AxisSpec:
76
+ """One axis entry parsed from the contract.
77
+
78
+ Mirrors the JSON shape directly — every field corresponds to a
79
+ ``axes[i]`` key, with sensible empties when the key is absent.
80
+
81
+ Attributes
82
+ ----------
83
+ name : str
84
+ The short axis name (e.g. ``"n"``, ``"p"``, ``"branch"``).
85
+ label : str
86
+ Human-readable label (e.g. ``"node"``, ``"process"``). Used in
87
+ :class:`FlexDataIntegrityError` to render beginner messages.
88
+ source_type : str
89
+ One of ``"entity_class"``, ``"entity_class_union"``,
90
+ ``"parameter_keys"``, ``"parameter_value_list"``, ``"synthetic"``.
91
+ source : Any
92
+ Raw ``source`` field from the contract. Shape depends on
93
+ ``source_type`` (see contract JSON schema).
94
+ filter : str | None
95
+ Free-text filter description. Informational only — vocabulary
96
+ construction does not apply scenario filters here (the Backend
97
+ does that upstream).
98
+ tokens : list[str] | None
99
+ Hardcoded tokens for ``source_type == "synthetic"`` axes.
100
+ column_synonyms : list[str]
101
+ Column names that should be cast against this axis enum. The
102
+ axis ``name`` itself is always implicit.
103
+ note : str | None
104
+ Free-text note for human readers.
105
+ """
106
+
107
+ name: str
108
+ label: str
109
+ source_type: str
110
+ source: Any
111
+ filter: str | None
112
+ tokens: list[str] | None
113
+ column_synonyms: list[str]
114
+ note: str | None
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class AxisContract:
119
+ """The full parsed contract.
120
+
121
+ Attributes
122
+ ----------
123
+ axes : tuple[AxisSpec, ...]
124
+ All axis rows in declaration order.
125
+ synthetic_token_allowlist : tuple[dict, ...]
126
+ Raw entries from ``synthetic_token_allowlist`` — each is a
127
+ ``{"axis": str, "tokens": list[str], ...}`` dict. The
128
+ ``build_axis_enums`` folds these into the right axis enum.
129
+ mixed_vocab_columns : dict
130
+ Raw ``mixed_vocab_columns`` block — ``{"confirmed": [...],
131
+ "pending_audit": [...]}``. Columns in ``confirmed`` are cast
132
+ against the ``e`` (entity union) axis enum.
133
+ non_dim_columns : dict
134
+ Raw ``non_dim_columns`` block — ``{"confirmed": [...]}``.
135
+ These columns are NOT cast (they hold data values, not
136
+ dimension tokens).
137
+ """
138
+
139
+ axes: tuple[AxisSpec, ...]
140
+ synthetic_token_allowlist: tuple[dict, ...]
141
+ mixed_vocab_columns: dict
142
+ non_dim_columns: dict
143
+ parameter_value_dtypes: dict = field(default_factory=dict)
144
+
145
+ def value_dtype_for(
146
+ self, entity_class: str, parameter: str,
147
+ ) -> pl.DataType:
148
+ """Return the leaf polars dtype declared for a parameter's value
149
+ column. Falls back to the contract's ``default`` (``Float64``
150
+ when the section is absent).
151
+
152
+ The dtype names in the contract (``"Float64"``, ``"Utf8"``,
153
+ ``"Boolean"``) are mapped here to polars types. An unknown
154
+ name in the contract raises ``ValueError`` — caller bug,
155
+ not user data.
156
+ """
157
+ section = self.parameter_value_dtypes or {}
158
+ overrides = section.get("overrides", {})
159
+ name = overrides.get(f"{entity_class}.{parameter}")
160
+ if name is None:
161
+ name = section.get("default", "Float64")
162
+ return _DTYPE_BY_NAME[name]
163
+
164
+ def by_name(self, name: str) -> AxisSpec:
165
+ """Look up an axis by its short name.
166
+
167
+ Raises
168
+ ------
169
+ KeyError
170
+ If *name* is not declared in the contract.
171
+ """
172
+ for axis in self.axes:
173
+ if axis.name == name:
174
+ return axis
175
+ raise KeyError(
176
+ f"axis {name!r} is not declared in the contract"
177
+ )
178
+
179
+ def column_to_axis(self, column_name: str) -> AxisSpec | None:
180
+ """Map a column name to the axis whose enum should cast it.
181
+
182
+ Resolution order:
183
+ 1. If *column_name* is in ``non_dim_columns.confirmed``, return
184
+ ``None`` (the column is NOT a dim — leave its dtype alone).
185
+ 2. If *column_name* is in ``mixed_vocab_columns.confirmed``,
186
+ return the ``e`` (entity union) axis.
187
+ 3. If a declared axis has *column_name* in its
188
+ ``column_synonyms``, return that axis.
189
+ 4. If *column_name* matches an axis ``name`` directly, return
190
+ that axis.
191
+ 5. Otherwise return ``None``.
192
+ """
193
+ if column_name in self.non_dim_columns.get("confirmed", []):
194
+ return None
195
+ if column_name in self.mixed_vocab_columns.get("confirmed", []):
196
+ try:
197
+ return self.by_name("e")
198
+ except KeyError:
199
+ return None
200
+ for axis in self.axes:
201
+ if column_name in axis.column_synonyms:
202
+ return axis
203
+ for axis in self.axes:
204
+ if axis.name == column_name:
205
+ return axis
206
+ return None
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Contract loader
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ def load_axis_contract(path: Path | None = None) -> AxisContract:
215
+ """Parse ``schemas/flextool_axis_contract.json`` into an :class:`AxisContract`.
216
+
217
+ Parameters
218
+ ----------
219
+ path : Path | None
220
+ Override the default contract path (used for testing). When
221
+ ``None``, defaults to ``<repo>/schemas/flextool_axis_contract.json``.
222
+
223
+ Raises
224
+ ------
225
+ FileNotFoundError
226
+ If *path* does not exist.
227
+ json.JSONDecodeError
228
+ If the file is not valid JSON.
229
+ """
230
+ if path is None:
231
+ path = _default_contract_path()
232
+ path = Path(path)
233
+ with path.open() as fh:
234
+ raw = json.load(fh)
235
+ axes_list = []
236
+ for row in raw.get("axes", []):
237
+ axes_list.append(AxisSpec(
238
+ name=row["name"],
239
+ label=row.get("label", row["name"]),
240
+ source_type=row["source_type"],
241
+ source=row.get("source"),
242
+ filter=row.get("filter"),
243
+ tokens=row.get("tokens"),
244
+ column_synonyms=list(row.get("column_synonyms", [])),
245
+ note=row.get("note"),
246
+ ))
247
+ return AxisContract(
248
+ axes=tuple(axes_list),
249
+ synthetic_token_allowlist=tuple(
250
+ raw.get("synthetic_token_allowlist", [])
251
+ ),
252
+ mixed_vocab_columns=dict(raw.get("mixed_vocab_columns", {})),
253
+ non_dim_columns=dict(raw.get("non_dim_columns", {})),
254
+ parameter_value_dtypes=dict(raw.get("parameter_value_dtypes", {})),
255
+ )
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Vocabulary builders
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ def _dedup_keep_order(items: Iterable[str]) -> list[str]:
264
+ """Return a list of *items* with duplicates removed; first
265
+ occurrence wins.
266
+
267
+ Used everywhere we union vocabularies — keeps the contract's
268
+ declaration order observable in the resulting enum.
269
+ """
270
+ seen: set[str] = set()
271
+ out: list[str] = []
272
+ for x in items:
273
+ if x in seen:
274
+ continue
275
+ seen.add(x)
276
+ out.append(x)
277
+ return out
278
+
279
+
280
+ def _entity_names(backend: Any, entity_class: str) -> list[str]:
281
+ """Return the entity names for *entity_class*.
282
+
283
+ ``backend.find_entities`` returns the raw spinedb_api rows; each
284
+ row's ``"name"`` field is the entity name we want for the enum
285
+ vocabulary.
286
+ """
287
+ rows = backend.find_entities(entity_class_name=entity_class)
288
+ return [row["name"] for row in rows]
289
+
290
+
291
+ def _allowlist_tokens_for(contract: AxisContract, axis_name: str) -> list[str]:
292
+ """Return the synthetic-allowlist tokens for *axis_name*, in
293
+ declaration order, deduplicated.
294
+ """
295
+ out: list[str] = []
296
+ for entry in contract.synthetic_token_allowlist:
297
+ if entry.get("axis") == axis_name:
298
+ out.extend(entry.get("tokens", []))
299
+ return _dedup_keep_order(out)
300
+
301
+
302
+ def _map_keys_at_depth(parsed_value: Any, depths: list[int]) -> int:
303
+ """Return the maximum nesting depth of a Spine Map ``parsed_value``.
304
+
305
+ Walks ``parsed_value.indexes`` / ``parsed_value.values`` recursively,
306
+ appending each level's depth to *depths*. Used to discover the
307
+ ``i`` axis vocabulary length (max depth of
308
+ ``commodity.price_ladder_*`` parameter maps).
309
+ """
310
+ # If the value is itself a Map, walk one level deeper.
311
+ if hasattr(parsed_value, "values") and hasattr(parsed_value, "indexes"):
312
+ # Each level contributes 1; recurse into the nested maps to find
313
+ # the deepest leaf.
314
+ local_depth = 1
315
+ children_max = 0
316
+ for v in parsed_value.values:
317
+ child_depth = _map_keys_at_depth(v, depths)
318
+ if child_depth > children_max:
319
+ children_max = child_depth
320
+ return local_depth + children_max
321
+ return 0
322
+
323
+
324
+ def _collect_parameter_map_keys(
325
+ backend: Any,
326
+ entity_class: str,
327
+ parameter: str,
328
+ ) -> list[str]:
329
+ """Collect the top-level keys of all maps in
330
+ ``(entity_class, parameter)``.
331
+
332
+ For each parameter-value row whose ``type == "map"``, gather the
333
+ ``parsed_value.indexes`` (top-level keys); union across all rows
334
+ preserving first-occurrence order.
335
+ """
336
+ rows = backend.find_parameter_values(
337
+ entity_class_name=entity_class,
338
+ parameter_definition_name=parameter,
339
+ )
340
+ out: list[str] = []
341
+ for param in rows:
342
+ if param.get("type") != "map":
343
+ continue
344
+ pv = param.get("parsed_value")
345
+ if pv is None or not hasattr(pv, "indexes"):
346
+ continue
347
+ for idx in pv.indexes:
348
+ out.append(str(idx))
349
+ return _dedup_keep_order(out)
350
+
351
+
352
+ def _collect_parameter_array_values(
353
+ backend: Any,
354
+ entity_class: str,
355
+ parameter: str,
356
+ ) -> list[str]:
357
+ """Collect the values of all array parameters in
358
+ ``(entity_class, parameter)``.
359
+
360
+ Used for sources like ``solve.realized_periods`` whose value is a
361
+ Spine Array of period labels.
362
+ """
363
+ rows = backend.find_parameter_values(
364
+ entity_class_name=entity_class,
365
+ parameter_definition_name=parameter,
366
+ )
367
+ out: list[str] = []
368
+ for param in rows:
369
+ if param.get("type") != "array":
370
+ continue
371
+ pv = param.get("parsed_value")
372
+ if pv is None or not hasattr(pv, "values"):
373
+ continue
374
+ for v in pv.values:
375
+ out.append(str(v))
376
+ return _dedup_keep_order(out)
377
+
378
+
379
+ def _collect_parameter_scalar_values(
380
+ backend: Any,
381
+ entity_class: str,
382
+ parameter: str,
383
+ ) -> list[str]:
384
+ """Collect the scalar string values of ``(entity_class, parameter)``.
385
+
386
+ Used for the ``branch`` axis (``solve.stochastic_branches`` is a
387
+ parameter whose scalar string value names the active branch).
388
+ """
389
+ rows = backend.find_parameter_values(
390
+ entity_class_name=entity_class,
391
+ parameter_definition_name=parameter,
392
+ )
393
+ out: list[str] = []
394
+ for param in rows:
395
+ ptype = param.get("type")
396
+ if ptype not in ("str", "float", "bool"):
397
+ continue
398
+ pv = param.get("parsed_value")
399
+ if pv is None:
400
+ continue
401
+ out.append(str(pv))
402
+ return _dedup_keep_order(out)
403
+
404
+
405
+ def _discover_tier_vocabulary(
406
+ backend: Any,
407
+ entity_class: str,
408
+ parameter_prefix: str,
409
+ ) -> list[str]:
410
+ """Discover the ``i`` (tier_index) vocabulary by collecting the
411
+ distinct tier-level keys across all parameter definitions in
412
+ *entity_class* whose name starts with *parameter_prefix*.
413
+
414
+ The ``commodity.price_ladder_*`` maps come in two shapes:
415
+
416
+ * ``price_ladder_cumulative``: depth-2 — ``tier → {price, quantity}``.
417
+ Tier keys live at the top level.
418
+ * ``price_ladder_annual``: depth-3 — ``period → tier → {price, quantity}``.
419
+ Tier keys live one nesting below the top.
420
+
421
+ The invariant across both shapes is that tier keys sit at the
422
+ level *immediately above the {price, quantity} leaves* — i.e. the
423
+ level whose immediate child Map's sub-indexes are non-Map scalars.
424
+ This helper walks the map dynamically and grabs index labels at
425
+ that depth, supporting both shapes uniformly.
426
+
427
+ Returns
428
+ -------
429
+ list[str]
430
+ Distinct tier keys in numeric-ascending order (or
431
+ first-occurrence order if any key is non-numeric). Empty
432
+ when no map parameter rows exist.
433
+ """
434
+ db = backend._db
435
+ if db is None: # pragma: no cover — backend lifecycle
436
+ return []
437
+ definitions = db.find_parameter_definitions()
438
+ candidates = [
439
+ d["name"] for d in definitions
440
+ if d.get("entity_class_name") == entity_class
441
+ and d.get("name", "").startswith(parameter_prefix)
442
+ ]
443
+ if not candidates:
444
+ return []
445
+
446
+ def _collect_tier_level(node, out: list[str]) -> None:
447
+ """Recursively walk *node* and append indexes at the tier
448
+ level (the level whose immediate children are leaf-only maps).
449
+ """
450
+ if not hasattr(node, "indexes") or not hasattr(node, "values"):
451
+ return
452
+ # Inspect the first child to decide whether THIS node is the
453
+ # tier level. A tier-level node's children are maps whose
454
+ # own children are non-Map scalars (the {price, quantity}
455
+ # leaves). If the first child is itself a map-of-maps,
456
+ # recurse into each child instead.
457
+ first_child = next(iter(node.values), None)
458
+ if first_child is None:
459
+ return
460
+ if hasattr(first_child, "values"):
461
+ grandchild = next(iter(first_child.values), None)
462
+ if grandchild is not None and hasattr(grandchild, "indexes"):
463
+ # Grandchild is itself a Map → first_child is NOT a
464
+ # tier-leaf map → recurse one level deeper.
465
+ for child in node.values:
466
+ _collect_tier_level(child, out)
467
+ return
468
+ # Either first_child is a leaf scalar (depth-1 map: caller is
469
+ # already at tier level — but that shouldn't happen for ladder
470
+ # params; treat defensively) or first_child is a map whose
471
+ # children are scalars (the {price, quantity} leaf map). In
472
+ # the latter case, THIS node's indexes are the tier labels.
473
+ for idx in node.indexes:
474
+ out.append(str(idx))
475
+
476
+ tier_keys: list[str] = []
477
+ for pname in candidates:
478
+ rows = backend.find_parameter_values(
479
+ entity_class_name=entity_class,
480
+ parameter_definition_name=pname,
481
+ )
482
+ for param in rows:
483
+ if param.get("type") != "map":
484
+ continue
485
+ pv = param.get("parsed_value")
486
+ if pv is None or not hasattr(pv, "values"):
487
+ continue
488
+ _collect_tier_level(pv, tier_keys)
489
+ unique = _dedup_keep_order(tier_keys)
490
+ try:
491
+ return sorted(unique, key=int)
492
+ except ValueError:
493
+ return unique
494
+
495
+
496
+ def _collect_stochastic_branch_period_tokens(backend: Any) -> list[str]:
497
+ """Enumerate forecast-branch period tokens from
498
+ ``solve.stochastic_branches``.
499
+
500
+ The cascade constructs stochastic forecast-branch period labels by
501
+ splicing each realised period with each declared branch name:
502
+ ``"{period}_{branch}"`` (see ``_stochastic.py:492`` /
503
+ ``_stochastic.py:522`` where ``solve_branch = period + "_" + branch``).
504
+ These tokens flow through the cascade as values in the period (``d``)
505
+ column of frames like ``period__branch.csv``,
506
+ ``period_in_use_set.csv``, and downstream constraint / parameter
507
+ frames (``nodeBalance_eq``, ``maxFlow``, ``maxState``,
508
+ ``process_constraint_equal``).
509
+
510
+ Without these tokens in the period-axis Enum vocabulary, the
511
+ cascade's non-strict cast nulls them silently, dropping 60-75 % of
512
+ the rows in the listed constraint frames and yielding a degraded LP
513
+ objective. Mirrors the ``branch.tokens`` allowlist pattern in
514
+ spirit, but the tokens are derived per-fixture (one per period ×
515
+ branch) rather than hard-coded.
516
+
517
+ The Spine Map for ``solve.stochastic_branches`` is shaped
518
+ ``period → branch → timestep → realized_yn``; level-1 keys are
519
+ periods, level-2 keys are branch names. We walk both levels and
520
+ emit the canonical ``"{period}_{branch}"`` token for every
521
+ encountered (period, branch) pair across all solve entities.
522
+
523
+ Returns
524
+ -------
525
+ list[str]
526
+ Forecast-branch period tokens in first-occurrence order; empty
527
+ for deterministic fixtures (where ``solve.stochastic_branches``
528
+ is empty / absent).
529
+ """
530
+ rows = backend.find_parameter_values(
531
+ entity_class_name="solve",
532
+ parameter_definition_name="stochastic_branches",
533
+ )
534
+ out: list[str] = []
535
+ for param in rows:
536
+ if param.get("type") != "map":
537
+ continue
538
+ pv = param.get("parsed_value")
539
+ if pv is None or not hasattr(pv, "indexes"):
540
+ continue
541
+ # Top level: period keys. Each value is itself a Map keyed by
542
+ # branch name.
543
+ for period, branch_map in zip(pv.indexes, pv.values):
544
+ period_str = str(period)
545
+ if not hasattr(branch_map, "indexes"):
546
+ continue
547
+ for branch in branch_map.indexes:
548
+ out.append(f"{period_str}_{branch}")
549
+ return _dedup_keep_order(out)
550
+
551
+
552
+ def _build_period_vocab(backend: Any, spec_source: dict) -> list[str]:
553
+ """Build the ``d`` (period) axis vocabulary.
554
+
555
+ Per the contract, ``d`` is sourced from
556
+ * keys of ``solve.years_represented`` (Map),
557
+ * values of ``solve.realized_periods`` (Array),
558
+ * keys of ``solve.invest_periods`` (Map),
559
+ * keys of ``solve.realized_invest_periods`` (Map).
560
+ All four are unioned (deterministic order).
561
+
562
+ For stochastic fixtures the period vocabulary is additionally
563
+ widened with the forecast-branch period tokens derived from
564
+ ``solve.stochastic_branches`` (see
565
+ :func:`_collect_stochastic_branch_period_tokens`). The cascade
566
+ constructs ``"{period}_{branch}"`` labels and casts them against
567
+ this Enum at multiple sites (period__branch overlay,
568
+ ``period_in_use_set``, ``nodeBalance_eq``, etc.); without the
569
+ widening the non-strict cast silently nulls 60-75 % of rows in
570
+ stochastic LPs.
571
+ """
572
+ entity_class = spec_source.get("entity_class", "solve")
573
+ params = spec_source.get("parameters", [])
574
+ out: list[str] = []
575
+ for param_name in params:
576
+ # Maps first (keys), arrays second (values).
577
+ keys = _collect_parameter_map_keys(backend, entity_class, param_name)
578
+ out.extend(keys)
579
+ vals = _collect_parameter_array_values(
580
+ backend, entity_class, param_name
581
+ )
582
+ out.extend(vals)
583
+ # Stochastic forecast-branch period tokens (empty for deterministic
584
+ # fixtures — _collect_parameter_values returns no map rows).
585
+ out.extend(_collect_stochastic_branch_period_tokens(backend))
586
+ return _dedup_keep_order(out)
587
+
588
+
589
+ def _build_block_vocab(backend: Any, spec_source: dict) -> list[str]:
590
+ """Build the ``block`` axis vocabulary.
591
+
592
+ Per the contract (``axes/block.source``), the block axis source is
593
+ ``group.new_stepduration`` with ``key_kind: "values_plus_default"``.
594
+ Block names are the names of ``group`` entities that carry a
595
+ non-null ``new_stepduration`` parameter value — regardless of
596
+ whether the parameter is authored as a per-period Map or as a
597
+ scalar-per-entity. The synthetic ``default`` token is appended
598
+ later by the ``tokens_default_extension`` allowlist (folded by the
599
+ caller via :func:`_allowlist_tokens_for` for the ``block`` axis).
600
+
601
+ When ``key_kind == "values_plus_default"`` we enumerate group
602
+ entities whose ``new_stepduration`` value is set to anything (Map
603
+ or scalar). For other ``key_kind`` values (or none specified) we
604
+ fall back to the original Map-key extraction for backwards
605
+ compatibility.
606
+ """
607
+ entity_class = spec_source.get("entity_class", "group")
608
+ parameter = spec_source.get("parameter", "new_stepduration")
609
+ key_kind = spec_source.get("key_kind")
610
+ if key_kind == "values_plus_default":
611
+ rows = backend.find_parameter_values(
612
+ entity_class_name=entity_class,
613
+ parameter_definition_name=parameter,
614
+ )
615
+ out: list[str] = []
616
+ for param in rows:
617
+ if param.get("parsed_value") is None:
618
+ continue
619
+ ent = param.get("entity_byname")
620
+ if not ent:
621
+ continue
622
+ # ``entity_byname`` is a tuple — for the single-dim 'group'
623
+ # class the entity name lives at index 0.
624
+ out.append(str(ent[0]))
625
+ return _dedup_keep_order(out)
626
+ return _collect_parameter_map_keys(backend, entity_class, parameter)
627
+
628
+
629
+ def build_axis_enums(
630
+ backend: Any,
631
+ contract: AxisContract,
632
+ ) -> dict[str, pl.Enum]:
633
+ """Build the ``{axis_name: pl.Enum}`` mapping from *contract* +
634
+ *backend*.
635
+
636
+ For each axis in the contract:
637
+ * ``entity_class`` — vocabulary = ``find_entities(class)`` names.
638
+ * ``entity_class_union`` — union across the listed classes
639
+ (first-occurrence order preserved).
640
+ * ``parameter_keys`` — vocabulary depends on the specific axis:
641
+ - ``t``: keys of ``timeline.timestep_duration`` map.
642
+ - ``d``: union of period keys/values across the four solve
643
+ parameters (see :func:`_build_period_vocab`).
644
+ - ``i``: integers ``"1"..."N"`` where N is the max map depth
645
+ across ``commodity.price_ladder_*`` parameters.
646
+ - ``block``: keys of ``group.new_stepduration`` maps.
647
+ - ``d_anchor``: empty here — populated per-solve at Phase 3
648
+ handoff.
649
+ * ``parameter_value_list`` — scalar values of the named parameter
650
+ (e.g. ``solve.stochastic_branches`` for ``branch``).
651
+ * ``synthetic`` — use ``spec.tokens`` verbatim.
652
+
653
+ Synthetic-allowlist tokens (e.g. ``eff``/``noEff`` for ``branch``,
654
+ ``default`` for ``block``) are appended to every axis's vocabulary.
655
+
656
+ Returns
657
+ -------
658
+ dict[str, pl.Enum]
659
+ Keyed by axis ``name``. Every contract axis appears in the
660
+ result; axes with empty vocabulary get an empty enum (still a
661
+ valid Enum dtype, just with zero categories).
662
+ """
663
+ out: dict[str, pl.Enum] = {}
664
+ for axis in contract.axes:
665
+ vocab: list[str] = []
666
+ st = axis.source_type
667
+ src = axis.source
668
+ if st == "entity_class":
669
+ vocab = _entity_names(backend, src)
670
+ elif st == "entity_class_union":
671
+ members: list[str] = []
672
+ for cls in src or []:
673
+ members.extend(_entity_names(backend, cls))
674
+ vocab = _dedup_keep_order(members)
675
+ elif st == "parameter_keys":
676
+ spec_src = src if isinstance(src, dict) else {}
677
+ if axis.name == "t":
678
+ vocab = _collect_parameter_map_keys(
679
+ backend,
680
+ spec_src.get("entity_class", "timeline"),
681
+ spec_src.get("parameter", "timestep_duration"),
682
+ )
683
+ elif axis.name == "d":
684
+ vocab = _build_period_vocab(backend, spec_src)
685
+ elif axis.name == "i":
686
+ # Per the contract i_axis_depth review note: discover
687
+ # the tier vocabulary by walking the level-2 keys of
688
+ # every ``commodity.price_ladder_*`` map. This emits
689
+ # the actual tier labels (typically integer strings
690
+ # ``"1"``..``"N"``) rather than a hard-coded ceiling.
691
+ vocab = _discover_tier_vocabulary(
692
+ backend,
693
+ spec_src.get("entity_class", "commodity"),
694
+ spec_src.get("parameter_prefix", "price_ladder_"),
695
+ )
696
+ elif axis.name == "block":
697
+ vocab = _build_block_vocab(backend, spec_src)
698
+ elif axis.name == "d_anchor":
699
+ # Per-solve carrier; built at Phase 3 handoff time.
700
+ vocab = []
701
+ else:
702
+ # Generic fallback: union over listed parameters.
703
+ ec = spec_src.get("entity_class")
704
+ params = spec_src.get("parameters") or []
705
+ if "parameter" in spec_src:
706
+ params = [spec_src["parameter"]]
707
+ gathered: list[str] = []
708
+ for pname in params:
709
+ if ec is None:
710
+ continue
711
+ gathered.extend(
712
+ _collect_parameter_map_keys(backend, ec, pname)
713
+ )
714
+ vocab = _dedup_keep_order(gathered)
715
+ elif st == "parameter_value_list":
716
+ spec_src = src if isinstance(src, dict) else {}
717
+ ec = spec_src.get("entity_class")
718
+ pn = spec_src.get("parameter")
719
+ if ec is not None and pn is not None:
720
+ vocab = _collect_parameter_scalar_values(backend, ec, pn)
721
+ else:
722
+ vocab = []
723
+ # Branch axis: the cascade column ``b`` (renamed from
724
+ # ``branch`` in _derived_branch.py:150) carries the
725
+ # ``"{period}_{branch}"`` sibling-period tokens emitted by
726
+ # _stochastic.py:492 (``solve_branch = period + "_" + branch``)
727
+ # plus the base period token itself. These flow through
728
+ # ``period__branch.csv`` / ``solve_branch_weight.csv`` and
729
+ # downstream joins; without them in the branch enum
730
+ # vocabulary, non-strict casts silently null the ``b``
731
+ # column on stochastic fixtures and break the LP joins.
732
+ if axis.name == "branch":
733
+ # Only widen for stochastic fixtures: if no
734
+ # ``solve.stochastic_branches`` entries exist, the
735
+ # period__branch overlay is not constructed and no
736
+ # base-period tokens flow into the ``b`` column.
737
+ # Polluting the branch vocab with base periods on
738
+ # deterministic fixtures changes the branch Enum and
739
+ # cascades through downstream joins that key on ``b``.
740
+ stoch_tokens = (
741
+ _collect_stochastic_branch_period_tokens(backend))
742
+ if stoch_tokens:
743
+ vocab = list(vocab)
744
+ vocab.extend(stoch_tokens)
745
+ # Base period tokens — ``period__branch.csv``
746
+ # includes ``(period, period)`` rows where the
747
+ # ``b`` value is the bare period name (see
748
+ # _stochastic.py:463).
749
+ base_periods: list[str] = []
750
+ for solve_param in ["years_represented",
751
+ "invest_periods",
752
+ "realized_invest_periods"]:
753
+ base_periods.extend(
754
+ _collect_parameter_map_keys(
755
+ backend, "solve", solve_param
756
+ )
757
+ )
758
+ base_periods.extend(
759
+ _collect_parameter_array_values(
760
+ backend, "solve", "realized_periods"
761
+ )
762
+ )
763
+ vocab.extend(base_periods)
764
+ vocab = _dedup_keep_order(vocab)
765
+ elif st == "synthetic":
766
+ vocab = list(axis.tokens or [])
767
+ else:
768
+ # Unknown source_type — empty vocabulary. Contract schema
769
+ # validation upstream should prevent this from happening in
770
+ # practice; we degrade gracefully here.
771
+ vocab = []
772
+
773
+ # Merge synthetic-allowlist tokens (always last; dedup keeps
774
+ # original tokens' priority).
775
+ vocab = _dedup_keep_order(
776
+ list(vocab) + _allowlist_tokens_for(contract, axis.name)
777
+ )
778
+ out[axis.name] = pl.Enum(vocab)
779
+ return out
780
+
781
+
782
+ # ---------------------------------------------------------------------------
783
+ # Cast helper + integrity error
784
+ # ---------------------------------------------------------------------------
785
+
786
+
787
+ _BANNER = "=" * 72
788
+
789
+
790
+ class FlexDataIntegrityError(ValueError):
791
+ """A column carried a token not in its axis enum vocabulary.
792
+
793
+ Raised by :func:`cast_against_contract` when a strict cast fails.
794
+ The message is a 4-paragraph beginner-friendly explanation: what
795
+ the unknown token is, where it appeared, the size of the declared
796
+ vocabulary, and a short list of next steps.
797
+ """
798
+
799
+ @classmethod
800
+ def from_cast_failure(
801
+ cls,
802
+ *,
803
+ axis_name: str,
804
+ axis_friendly: str,
805
+ bad_token: str,
806
+ vocabulary_size: int,
807
+ parameter: str | None = None,
808
+ entity: str | None = None,
809
+ map_index: str | None = None,
810
+ scenario: str | None = None,
811
+ suggestions: Sequence[str] | None = None,
812
+ ) -> "FlexDataIntegrityError":
813
+ """Render the canonical 4-paragraph error message.
814
+
815
+ Parameters
816
+ ----------
817
+ axis_name : str
818
+ Short axis name (e.g. ``"n"``) — used to build the
819
+ ``axis_friendly`` plural in the third paragraph.
820
+ axis_friendly : str
821
+ Human-readable axis label (e.g. ``"node"``) — used in the
822
+ opening line and the cardinality sentence.
823
+ bad_token : str
824
+ The token that wasn't in the enum vocabulary.
825
+ vocabulary_size : int
826
+ Number of declared tokens; rendered in the cardinality
827
+ sentence (``"Your input database lists {N} nodes."``).
828
+ parameter, entity, map_index, scenario : str | None
829
+ Origin breadcrumbs. Each becomes a line in the "Where it
830
+ appeared" block; missing breadcrumbs are replaced with a
831
+ ``(unknown)`` placeholder so the message shape stays
832
+ stable.
833
+ suggestions : sequence of str | None
834
+ Up to 4 hints rendered as bullets in the final paragraph.
835
+ None / empty means a generic "add the entity" fallback
836
+ bullet is appended.
837
+ """
838
+ # Plural: just append 's' — the axis labels in the contract are
839
+ # all simple nouns (node, process, commodity, ...) that
840
+ # pluralise cleanly. If a future label breaks this rule, the
841
+ # cast helper still raises the error correctly — only the
842
+ # cardinality sentence reads slightly off.
843
+ plural = axis_friendly + "s" if not axis_friendly.endswith("s") else axis_friendly
844
+
845
+ where_lines = [
846
+ f" In parameter: {parameter or '(unknown)'}",
847
+ f" On entity: {entity or '(unknown)'}",
848
+ f" Inside a map at: key index {map_index or '(unknown)'}",
849
+ f" Active scenario: {scenario or '(unknown)'}",
850
+ ]
851
+
852
+ if suggestions is None or len(suggestions) == 0:
853
+ bullets = [
854
+ f" - Check {axis_friendly} name {bad_token!r} for a typo against your DB.",
855
+ f" - If {bad_token!r} is truly new, add it to the source DB under "
856
+ f"the {axis_friendly} entity class.",
857
+ ]
858
+ else:
859
+ bullets = [f" - {hint}" for hint in suggestions[:4]]
860
+ if not any("add" in b.lower() and "DB" in b for b in bullets):
861
+ # Always close with the "add to DB" fallback if the
862
+ # caller didn't already include it.
863
+ if len(bullets) < 4:
864
+ bullets.append(
865
+ f" - If {bad_token!r} is truly new, add it to the source "
866
+ f"DB under the {axis_friendly} entity class."
867
+ )
868
+
869
+ para3_tail = (
870
+ f' is not one of them. This token appeared while the cascade was '
871
+ f'reading {parameter or "an input parameter"} and casting it to '
872
+ f'the {axis_friendly} axis enum.'
873
+ )
874
+
875
+ msg = (
876
+ f"\n{_BANNER}\n"
877
+ f"Found an unknown {axis_friendly} name {bad_token!r} "
878
+ f"in your input data.\n"
879
+ f"\n"
880
+ f"Where it appeared:\n"
881
+ + "\n".join(where_lines) + "\n"
882
+ f"\n"
883
+ f"Your input database lists {vocabulary_size} {plural}. "
884
+ f"{bad_token!r}{para3_tail}\n"
885
+ f"\n"
886
+ f"What to do:\n"
887
+ + "\n".join(bullets) + "\n"
888
+ f"{_BANNER}"
889
+ )
890
+ return cls(msg)
891
+
892
+
893
+ def _levenshtein(a: str, b: str) -> int:
894
+ """Compute the Levenshtein edit distance between *a* and *b*.
895
+
896
+ Small standalone implementation — no external dep. Used by
897
+ :func:`_lookup_similar_classes` to surface "did you mean ...?"
898
+ suggestions in the integrity-error message.
899
+ """
900
+ if a == b:
901
+ return 0
902
+ if len(a) < len(b):
903
+ a, b = b, a
904
+ if len(b) == 0:
905
+ return len(a)
906
+ prev = list(range(len(b) + 1))
907
+ for i, ca in enumerate(a, start=1):
908
+ curr = [i]
909
+ for j, cb in enumerate(b, start=1):
910
+ cost = 0 if ca == cb else 1
911
+ curr.append(min(
912
+ curr[j - 1] + 1, # insert
913
+ prev[j] + 1, # delete
914
+ prev[j - 1] + cost, # substitute
915
+ ))
916
+ prev = curr
917
+ return prev[-1]
918
+
919
+
920
+ def _lookup_similar_classes(
921
+ token: str,
922
+ backend: Any,
923
+ intended_axis: AxisSpec,
924
+ all_classes: list[str],
925
+ ) -> list[str]:
926
+ """Build a short list of suggestion hints for the bad *token*.
927
+
928
+ Cross-class lookup (most actionable):
929
+ For each entity class in *all_classes*, check whether *token* is
930
+ an entity in that class. If found, prepend a hint like
931
+ ``"the commodity 'token'"`` — beginner-helpful when the user
932
+ typed e.g. a commodity name in a node column.
933
+
934
+ Levenshtein typo suggestions:
935
+ Compute edit distance from *token* against every entity name in
936
+ the *intended_axis*'s source class. Return up to 2 candidates
937
+ with distance ≤ 2.
938
+
939
+ The total list is capped at 3 hints so the
940
+ :class:`FlexDataIntegrityError` rendering has room for the
941
+ ``add this entity if truly new`` fallback as the 4th bullet.
942
+ """
943
+ hints: list[str] = []
944
+ # 1. Cross-class lookup.
945
+ for cls in all_classes:
946
+ try:
947
+ rows = backend.find_entities(entity_class_name=cls)
948
+ except Exception: # noqa: BLE001 — defensive
949
+ continue
950
+ for r in rows:
951
+ if r.get("name") == token:
952
+ hints.append(f"{token!r} exists as a {cls}, not a {intended_axis.label}.")
953
+ # Cross-class hit is the strongest signal; stop after one.
954
+ break
955
+ if hints:
956
+ break
957
+
958
+ # 2. Levenshtein typo suggestions within the intended class.
959
+ intended_classes: list[str] = []
960
+ if intended_axis.source_type == "entity_class":
961
+ intended_classes = [str(intended_axis.source)]
962
+ elif intended_axis.source_type == "entity_class_union":
963
+ intended_classes = list(intended_axis.source or [])
964
+ typo_candidates: list[tuple[int, str]] = []
965
+ for cls in intended_classes:
966
+ try:
967
+ rows = backend.find_entities(entity_class_name=cls)
968
+ except Exception: # noqa: BLE001 — defensive
969
+ continue
970
+ for r in rows:
971
+ name = r.get("name")
972
+ if not name or name == token:
973
+ continue
974
+ dist = _levenshtein(token, name)
975
+ if dist <= 2:
976
+ typo_candidates.append((dist, name))
977
+ typo_candidates.sort()
978
+ for _dist, name in typo_candidates[:2]:
979
+ hints.append(f"did you mean {name!r}?")
980
+
981
+ return hints[:3]
982
+
983
+
984
+ def cast_against_contract(
985
+ frame: pl.DataFrame,
986
+ *,
987
+ contract: AxisContract,
988
+ axis_enums: dict[str, pl.Enum],
989
+ origin: dict | None = None,
990
+ backend: Any | None = None,
991
+ ) -> pl.DataFrame:
992
+ """Cast every dim column of *frame* to its canonical enum dtype.
993
+
994
+ For each column in ``frame.columns``:
995
+ * If :meth:`AxisContract.column_to_axis` resolves it to an
996
+ :class:`AxisSpec`, cast the column to ``axis_enums[axis.name]``
997
+ with ``strict=True``.
998
+ * On cast failure (a token not in the enum vocabulary), raise
999
+ :class:`FlexDataIntegrityError` with the *origin* breadcrumbs
1000
+ threaded in.
1001
+ * If no axis maps the column, leave it alone.
1002
+
1003
+ Parameters
1004
+ ----------
1005
+ frame : pl.DataFrame
1006
+ Eager frame to cast. Returned unchanged if no dim columns are
1007
+ present.
1008
+ contract : AxisContract
1009
+ The canonical axis contract.
1010
+ axis_enums : dict[str, pl.Enum]
1011
+ Result of :func:`build_axis_enums`.
1012
+ origin : dict | None
1013
+ Optional breadcrumb dict — supported keys: ``parameter``,
1014
+ ``entity``, ``map_index``, ``scenario``. Threaded into
1015
+ :meth:`FlexDataIntegrityError.from_cast_failure`.
1016
+ backend : Any | None
1017
+ Optional :class:`SpineDBBackend` — used by
1018
+ :func:`_lookup_similar_classes` to render
1019
+ "did you mean ...?" suggestions in the error message. When
1020
+ ``None``, the error still renders, just without suggestions.
1021
+
1022
+ Returns
1023
+ -------
1024
+ pl.DataFrame
1025
+ The cast frame (or the original if no dim columns were
1026
+ present).
1027
+ """
1028
+ origin = dict(origin or {})
1029
+ cast_exprs = []
1030
+ cast_pairs: list[tuple[str, AxisSpec]] = []
1031
+ for col in frame.columns:
1032
+ axis = contract.column_to_axis(col)
1033
+ if axis is None:
1034
+ continue
1035
+ dtype = axis_enums.get(axis.name)
1036
+ if dtype is None:
1037
+ continue
1038
+ # Polars' numeric → Enum cast interprets the numeric value as a
1039
+ # POSITIONAL INDEX into the enum's categories — so a Float64 / Int
1040
+ # column whose name happens to match an axis synonym (e.g. a
1041
+ # Spine 1d_map[<constraint-like-key-name>] where the keys are
1042
+ # numeric durations) would silently get reinterpreted as
1043
+ # category-by-position. That is a contract-axis bug, not a
1044
+ # vocabulary mismatch: numeric columns are never dim columns
1045
+ # under this contract. Skip the cast.
1046
+ src_dtype = frame.schema[col]
1047
+ if src_dtype.is_numeric():
1048
+ continue
1049
+ cast_pairs.append((col, axis))
1050
+ cast_exprs.append(pl.col(col).cast(dtype, strict=True))
1051
+
1052
+ if not cast_exprs:
1053
+ return frame
1054
+
1055
+ try:
1056
+ return frame.with_columns(cast_exprs)
1057
+ except pl.exceptions.InvalidOperationError as exc:
1058
+ # Find which column / token failed. Polars only reports the
1059
+ # column / value, not the axis — we walk the cast pairs ourself
1060
+ # and raise on the first miss.
1061
+ bad_axis: AxisSpec | None = None
1062
+ bad_token: str = "<unknown>"
1063
+ for col, axis in cast_pairs:
1064
+ dtype = axis_enums[axis.name]
1065
+ vocab = set(dtype.categories.to_list())
1066
+ values = frame[col].to_list()
1067
+ for v in values:
1068
+ if v is None:
1069
+ continue
1070
+ if v not in vocab:
1071
+ bad_axis = axis
1072
+ bad_token = str(v)
1073
+ break
1074
+ if bad_axis is not None:
1075
+ break
1076
+
1077
+ if bad_axis is None:
1078
+ # Couldn't identify the offending value — re-raise with a
1079
+ # softer integrity error using the original exception text.
1080
+ raise FlexDataIntegrityError(
1081
+ f"polars rejected the dim-column cast: {exc}"
1082
+ ) from exc
1083
+
1084
+ all_classes = []
1085
+ for ax in contract.axes:
1086
+ if ax.source_type == "entity_class":
1087
+ all_classes.append(str(ax.source))
1088
+ elif ax.source_type == "entity_class_union":
1089
+ all_classes.extend(list(ax.source or []))
1090
+ all_classes = _dedup_keep_order(all_classes)
1091
+
1092
+ if backend is not None:
1093
+ suggestions = _lookup_similar_classes(
1094
+ bad_token, backend, bad_axis, all_classes,
1095
+ )
1096
+ else:
1097
+ suggestions = []
1098
+
1099
+ raise FlexDataIntegrityError.from_cast_failure(
1100
+ axis_name=bad_axis.name,
1101
+ axis_friendly=bad_axis.label,
1102
+ bad_token=bad_token,
1103
+ vocabulary_size=len(axis_enums[bad_axis.name].categories),
1104
+ parameter=origin.get("parameter"),
1105
+ entity=origin.get("entity"),
1106
+ map_index=origin.get("map_index"),
1107
+ scenario=origin.get("scenario"),
1108
+ suggestions=suggestions,
1109
+ ) from exc
1110
+
1111
+
1112
+ __all__ = [
1113
+ "AxisSpec",
1114
+ "AxisContract",
1115
+ "load_axis_contract",
1116
+ "build_axis_enums",
1117
+ "cast_against_contract",
1118
+ "FlexDataIntegrityError",
1119
+ ]