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,876 @@
1
+ """Build SheetSpec objects describing every sheet to generate in the Excel output.
2
+
3
+ Takes DatabaseContents (from db_reader) and YAML settings to produce an ordered
4
+ list of SheetSpec, each encoding the layout, entity columns, parameter columns,
5
+ and special rules for one Excel sheet.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ from flextool.export_to_tabular.db_reader import DatabaseContents
17
+
18
+
19
+ @dataclass
20
+ class SheetSpec:
21
+ """Description of a single Excel sheet to generate."""
22
+
23
+ sheet_name: str
24
+ layout: str # 'constant', 'periodic', 'timeseries', 'link', 'scenario', 'navigate', 'version'
25
+ entity_classes: list[str] # DB entity classes that contribute data
26
+ entity_columns: list[str] # Column header names for entity dimensions
27
+ parameter_names: list[str] = field(default_factory=list)
28
+ direction_column: str | None = None # e.g. 'input_output' for merged unit__inputNode/outputNode
29
+ direction_map: dict[str, str] = field(default_factory=dict) # entity_class -> direction value
30
+ extra_entity_columns: list[str] = field(default_factory=list) # e.g. ['left_node', 'right_node']
31
+ extra_entity_class: str | None = None # class to get extra elements from
32
+ unpack_index_column: str | None = None # e.g. 'constraint' for unpacked Map params
33
+ has_entity_alternative: bool = True # whether to include Entity Alternative column
34
+ pre_ea_params: list[str] = field(default_factory=list) # params before Entity Alternative column
35
+ index_name_default: str | None = None # fallback index column name for this layout
36
+ descriptions: dict[str, str] = field(default_factory=dict) # param_name -> description text
37
+ # Periodic-layout only: params whose SCALAR values are written on a sibling
38
+ # constant sheet (e.g. node.existing lives on both node_c and node_p because
39
+ # it is float for some entities, 1d-map for others). The periodic writer
40
+ # MUST NOT re-emit a scalar-only row for these — doing so duplicates the
41
+ # value and collides on import round-trip. Empty for split-params periodic
42
+ # sheets (e.g. 'solve'), where scalar params have no constant home and the
43
+ # scalar-only row is the only carrier.
44
+ scalar_params_on_constant_sibling: set[str] = field(default_factory=set)
45
+
46
+
47
+ def load_settings() -> dict:
48
+ """Load export_settings.yaml from the same directory as this module."""
49
+ settings_path = Path(__file__).parent / "export_settings.yaml"
50
+ with open(settings_path, encoding="utf-8") as f:
51
+ return yaml.safe_load(f)
52
+
53
+
54
+ def _has_facet_leaf_shape(entity_class: str, param_name: str) -> bool:
55
+ """Return True when ``(entity_class, param_name)`` is registered with a
56
+ facet-leaf shape (``LeafKind.FACET_PRICE_QUANTITY``).
57
+
58
+ Consults :data:`PARAM_ALLOWED_SHAPES` lazily to avoid pulling polars on
59
+ the export hot path.
60
+ """
61
+ if not entity_class or not param_name:
62
+ return False
63
+ try:
64
+ from flextool.engine_polars._param_shapes import (
65
+ LeafKind,
66
+ PARAM_ALLOWED_SHAPES,
67
+ shape_to_axes,
68
+ )
69
+ except Exception: # pragma: no cover — registry must be importable
70
+ return False
71
+ allowed = PARAM_ALLOWED_SHAPES.get((entity_class, param_name))
72
+ if not allowed:
73
+ return False
74
+ for shape in allowed:
75
+ if shape_to_axes(shape).leaf is LeafKind.FACET_PRICE_QUANTITY:
76
+ return True
77
+ return False
78
+
79
+
80
+ def classify_param_types(
81
+ param_type_list: tuple | None,
82
+ entity_class: str = "",
83
+ param_name: str = "",
84
+ ) -> set[str]:
85
+ """Given a parameter's type_list tuple, return which sheet layouts it can appear on.
86
+
87
+ Returns a set that may contain ``'constant'``, ``'periodic'``,
88
+ ``'timeseries'``, ``'stochastic'``, or ``'ladder'``.
89
+
90
+ When the registry tags ``(entity_class, param_name)`` with a
91
+ ``FACET_PRICE_QUANTITY`` leaf shape, the result becomes ``{'ladder'}``
92
+ — the param is NOT a generic stochastic / nested-periodic param and
93
+ must NOT share a sheet with constants. ``stochastic`` is suppressed
94
+ even when the type-list contains ``3d_map`` (the depth-3 variant of
95
+ ``commodity.price_ladder_annual``); the third Map level carries the
96
+ ``{price, quantity}`` facet, NOT a temporal axis.
97
+ """
98
+ # Registry override for facet-leaf params (commodity.price_ladder_*):
99
+ # route exclusively to the dedicated ``ladder`` layout regardless of
100
+ # the schema-declared type-list.
101
+ if _has_facet_leaf_shape(entity_class, param_name):
102
+ return {"ladder"}
103
+
104
+ if param_type_list is None:
105
+ return {"constant"}
106
+
107
+ result: set[str] = set()
108
+ types = set(param_type_list)
109
+
110
+ if "float" in types or "str" in types:
111
+ result.add("constant")
112
+ if "array" in types:
113
+ result.add("constant")
114
+ if "2d_map" in types:
115
+ result.add("constant")
116
+
117
+ if "1d_map" in types:
118
+ # If 1d_map coexists with 3d_map but has no float/str constant fallback,
119
+ # the 1d_map variant is a simpler time-series (not period-indexed).
120
+ # Only add 'periodic' when there's also a constant type or no 3d_map.
121
+ if "3d_map" in types and not (types & {"float", "str"}):
122
+ # Pure time-series param (e.g. profile: ('1d_map', '3d_map'))
123
+ result.add("timeseries")
124
+ else:
125
+ result.add("periodic")
126
+ if "3d_map" in types:
127
+ result.add("timeseries")
128
+
129
+ if "3d_map" in types:
130
+ result.add("stochastic")
131
+ result.add("timeseries")
132
+
133
+ if "4d_map" in types:
134
+ result.add("stochastic")
135
+
136
+ return result
137
+
138
+
139
+ def _has_time_indexed_values(
140
+ db_contents: DatabaseContents,
141
+ entity_class: str,
142
+ param_name: str,
143
+ ) -> bool:
144
+ """Check whether any actual parameter value for this param is time-indexed.
145
+
146
+ Uses the IndexClassifier from excel_writer if available, falling back to
147
+ a simple index_name=='time' check.
148
+ """
149
+ from flextool.export_to_tabular.excel_writer import _is_time_indexed_map
150
+ from spinedb_api import Map
151
+
152
+ for (cls, _byname, pname, _alt), value in db_contents.parameter_values.items():
153
+ if cls == entity_class and pname == param_name and isinstance(value, Map):
154
+ if _is_time_indexed_map(value):
155
+ return True
156
+ return False
157
+
158
+
159
+ def _is_string_param(pname: str, entity_class: str, db_contents: DatabaseContents) -> bool:
160
+ """Check if a parameter is string-valued (type_list contains 'str')."""
161
+ for pdef in db_contents.parameter_definitions.get(entity_class, []):
162
+ if pdef["name"] == pname:
163
+ type_list = pdef.get("parameter_type_list")
164
+ if type_list and "str" in type_list:
165
+ return True
166
+ return False
167
+ return False
168
+
169
+
170
+ def order_parameters(
171
+ param_names: list[str],
172
+ entity_class: str,
173
+ db_contents: DatabaseContents,
174
+ ) -> list[str]:
175
+ """Sort parameters: string-typed first, then numeric, each by group priority and name.
176
+
177
+ This ensures the string/float column-type boundary in the import spec
178
+ lines up correctly for roundtrip xlsx compatibility.
179
+ """
180
+ {
181
+ p["name"]: p for p in db_contents.parameter_definitions.get(entity_class, [])
182
+ }
183
+
184
+ string_grouped: list[tuple[int, str]] = []
185
+ string_ungrouped: list[str] = []
186
+ float_grouped: list[tuple[int, str]] = []
187
+ float_ungrouped: list[str] = []
188
+
189
+ for pname in param_names:
190
+ is_str = _is_string_param(pname, entity_class, db_contents)
191
+
192
+ group_name = db_contents.param_to_group.get((entity_class, pname))
193
+ if group_name and group_name in db_contents.parameter_groups:
194
+ priority = db_contents.parameter_groups[group_name].get("priority") or 999
195
+ if is_str:
196
+ string_grouped.append((priority, pname))
197
+ else:
198
+ float_grouped.append((priority, pname))
199
+ else:
200
+ if is_str:
201
+ string_ungrouped.append(pname)
202
+ else:
203
+ float_ungrouped.append(pname)
204
+
205
+ string_grouped.sort(key=lambda x: (x[0], x[1]))
206
+ string_ungrouped.sort()
207
+ float_grouped.sort(key=lambda x: (x[0], x[1]))
208
+ float_ungrouped.sort()
209
+
210
+ return (
211
+ [p[1] for p in string_grouped] + string_ungrouped
212
+ + [p[1] for p in float_grouped] + float_ungrouped
213
+ )
214
+
215
+
216
+ def _get_param_defs_dict(
217
+ db_contents: DatabaseContents, entity_class: str
218
+ ) -> dict[str, dict]:
219
+ """Return {param_name: pdef_dict} for a given entity class."""
220
+ return {
221
+ pdef["name"]: pdef
222
+ for pdef in db_contents.parameter_definitions.get(entity_class, [])
223
+ }
224
+
225
+
226
+ def _get_dimension_names(db_contents: DatabaseContents, class_name: str) -> tuple:
227
+ """Get dimension_name_list for a class."""
228
+ for ec in db_contents.entity_classes:
229
+ if ec["name"] == class_name:
230
+ return ec["dimension_name_list"]
231
+ return ()
232
+
233
+
234
+ def _make_base_name(class_name: str, drop_dims: list[str]) -> str:
235
+ """Convert a DB entity class name to a sheet base name.
236
+
237
+ Replaces ``__`` with ``_`` and drops any dimension names in *drop_dims*.
238
+ """
239
+ parts = class_name.split("__")
240
+ filtered = [p for p in parts if p not in drop_dims]
241
+ return "_".join(filtered)
242
+
243
+
244
+ def _sheet_name_in_order(name: str, sheet_order: list[str]) -> bool:
245
+ """Check if a sheet name is present in the YAML sheet_order list."""
246
+ return name in sheet_order
247
+
248
+
249
+ def build_sheet_specs(
250
+ db_contents: DatabaseContents,
251
+ settings: dict | None = None,
252
+ include_groups: set[str] | None = None,
253
+ ) -> list[SheetSpec]:
254
+ """Build the ordered list of SheetSpec objects for all Excel sheets.
255
+
256
+ Args:
257
+ db_contents: Database contents from :func:`db_reader.read_database`.
258
+ settings: Parsed YAML settings dict, or *None* to load from disk.
259
+ include_groups: When given, restrict output to parameters whose
260
+ ``parameter_group_name`` is in the set. Parameters without a
261
+ group are dropped. Sheets whose column set goes empty are
262
+ dropped, unless listed under a selected group in
263
+ ``always_include_with_groups``.
264
+
265
+ Returns:
266
+ Ordered list of :class:`SheetSpec` objects.
267
+ """
268
+ if settings is None:
269
+ settings = load_settings()
270
+
271
+ # ---- Build lookup structures from settings ----
272
+ merge_rules: dict[str, dict] = settings.get("merge_classes", {})
273
+ split_rules: dict[str, dict] = settings.get("split_params", {})
274
+ unpack_rules: dict[str, dict] = settings.get("unpack_map_params", {})
275
+ element_rules: dict[str, dict] = settings.get("add_entity_elements", {})
276
+ sheet_order: list[str] = settings.get("sheet_order", [])
277
+ drop_dims: list[str] = settings.get("drop_dimensions", [])
278
+ rename_sheets: dict[str, str] = settings.get("rename_sheets", {})
279
+ ea_classes: set[str] = set(settings.get("entity_alternative_classes", []))
280
+ pre_ea_rules: dict[str, list[str]] = settings.get("pre_entity_alternative_params", {})
281
+ index_name_defaults: dict[str, str] = settings.get("index_name_defaults", {})
282
+
283
+ # Classes that are used as extra entity element sources (skip in generic path)
284
+ element_source_classes: set[str] = set()
285
+ for _cls, erule in element_rules.items():
286
+ element_source_classes.add(erule["from_class"])
287
+
288
+ # Sets of classes handled by merge/split/unpack so the generic path skips them
289
+ merged_classes: set[str] = set()
290
+ for _group_name, rule in merge_rules.items():
291
+ for cls_name in rule["classes"]:
292
+ merged_classes.add(cls_name)
293
+
294
+ split_classes: set[str] = set(split_rules.keys())
295
+
296
+ # Map from source class -> list of unpack rules
297
+ unpack_source_classes: dict[str, list[dict[str, Any]]] = {}
298
+ # Track which params are unpacked per class
299
+ unpacked_params_per_class: dict[str, set[str]] = {}
300
+ for group_name, rule in unpack_rules.items():
301
+ for src_cls in rule["source_classes"]:
302
+ unpack_source_classes.setdefault(src_cls, []).append(
303
+ {"group_name": group_name, **rule}
304
+ )
305
+ unpacked_params_per_class.setdefault(src_cls, set()).update(rule["params"])
306
+
307
+ handled_classes: set[str] = set()
308
+ specs: list[SheetSpec] = []
309
+
310
+ # ---- 1. Process merge_classes rules ----
311
+ for group_name, rule in merge_rules.items():
312
+ classes_map: dict[str, str] = rule["classes"] # class_name -> direction_value
313
+ direction_col: str = rule["direction_column"]
314
+ class_names = list(classes_map.keys())
315
+
316
+ # Collect ALL param defs from the merged classes (they share the same params)
317
+ all_param_defs: dict[str, dict] = {}
318
+ for cls_name in class_names:
319
+ for pdef in db_contents.parameter_definitions.get(cls_name, []):
320
+ all_param_defs[pdef["name"]] = pdef
321
+
322
+ # Filter out unpacked params
323
+ unpacked: set[str] = set()
324
+ for cls_name in class_names:
325
+ unpacked.update(unpacked_params_per_class.get(cls_name, set()))
326
+ remaining_params = {
327
+ k: v for k, v in all_param_defs.items() if k not in unpacked
328
+ }
329
+
330
+ # Classify params by eligible layouts
331
+ layout_params: dict[str, list[str]] = {
332
+ "constant": [],
333
+ "periodic": [],
334
+ "timeseries": [],
335
+ }
336
+ for pname, pdef in remaining_params.items():
337
+ types = classify_param_types(
338
+ pdef.get("parameter_type_list"),
339
+ entity_class=class_names[0],
340
+ param_name=pname,
341
+ )
342
+ if "constant" in types:
343
+ layout_params["constant"].append(pname)
344
+ if "periodic" in types:
345
+ layout_params["periodic"].append(pname)
346
+ if "timeseries" in types:
347
+ layout_params["timeseries"].append(pname)
348
+ # Check for actual time-indexed values across merged classes
349
+ if "periodic" in types and "timeseries" not in types:
350
+ for cls_name in class_names:
351
+ if _has_time_indexed_values(db_contents, cls_name, pname):
352
+ layout_params["timeseries"].append(pname)
353
+ break
354
+
355
+ # Determine entity_columns from first class (all merged should match)
356
+ dims = _get_dimension_names(db_contents, class_names[0])
357
+ entity_cols = list(dims) if dims else [class_names[0]]
358
+
359
+ # Determine suffix rules
360
+ has_layouts = [k for k in ("constant", "periodic", "timeseries") if layout_params[k]]
361
+ use_suffix = len(has_layouts) > 1
362
+
363
+ direction_map = dict(classes_map)
364
+ layout_to_suffix = {"constant": "_c", "periodic": "_p", "timeseries": "_t"}
365
+
366
+ for layout_key in ("constant", "periodic", "timeseries"):
367
+ params = layout_params[layout_key]
368
+ if not params:
369
+ continue
370
+
371
+ suffix = layout_to_suffix[layout_key] if use_suffix else ""
372
+ if not use_suffix and layout_key == "constant":
373
+ if _sheet_name_in_order(f"{group_name}_c", sheet_order):
374
+ suffix = "_c"
375
+
376
+ sheet_nm = f"{group_name}{suffix}"
377
+ has_ea = layout_key == "constant" and any(
378
+ c in ea_classes for c in class_names
379
+ )
380
+
381
+ spec = SheetSpec(
382
+ sheet_name=sheet_nm,
383
+ layout=layout_key,
384
+ entity_classes=class_names,
385
+ entity_columns=entity_cols,
386
+ direction_column=direction_col,
387
+ direction_map=direction_map,
388
+ has_entity_alternative=has_ea,
389
+ )
390
+ if layout_key == "periodic":
391
+ spec.scalar_params_on_constant_sibling = (
392
+ set(params) & set(layout_params["constant"])
393
+ )
394
+ spec._raw_params = params # type: ignore[attr-defined]
395
+ spec._primary_class = class_names[0] # type: ignore[attr-defined]
396
+ spec._all_param_defs = all_param_defs # type: ignore[attr-defined]
397
+ specs.append(spec)
398
+
399
+ for cls_name in class_names:
400
+ handled_classes.add(cls_name)
401
+
402
+ # ---- 2. Process split_params rules ----
403
+ for entity_class, sub_groups in split_rules.items():
404
+ class_pdefs = _get_param_defs_dict(db_contents, entity_class)
405
+ dims = _get_dimension_names(db_contents, entity_class)
406
+ entity_cols = list(dims) if dims else [entity_class]
407
+
408
+ for sub_name, sub_rule in sub_groups.items():
409
+ sub_params = sub_rule["params"]
410
+ # An explicit ``layout:`` key in the sub-rule overrides type
411
+ # inference. Used to route a nested-Map param that the schema
412
+ # declares as ``2d_map`` (which type inference would treat as
413
+ # periodic, where the Map value is dropped) onto the multi-index
414
+ # stochastic writer instead — e.g. timeset_s /
415
+ # representative_period_weights.
416
+ explicit_layout = sub_rule.get("layout")
417
+ # Determine layout from param types in the sub-group
418
+ has_periodic = False
419
+ has_stochastic = False
420
+ for pname in sub_params:
421
+ pdef = class_pdefs.get(pname)
422
+ if pdef:
423
+ type_list = pdef.get("parameter_type_list")
424
+ if type_list and ("1d_map" in type_list or "2d_map" in type_list):
425
+ has_periodic = True
426
+ if type_list and ("3d_map" in type_list or "4d_map" in type_list):
427
+ has_stochastic = True
428
+
429
+ if explicit_layout:
430
+ layout = explicit_layout
431
+ elif has_stochastic:
432
+ layout = "stochastic"
433
+ elif has_periodic:
434
+ layout = "periodic"
435
+ else:
436
+ layout = "constant"
437
+
438
+ spec = SheetSpec(
439
+ sheet_name=sub_name,
440
+ layout=layout,
441
+ entity_classes=[entity_class],
442
+ entity_columns=entity_cols,
443
+ has_entity_alternative=(layout == "constant" and entity_class in ea_classes),
444
+ )
445
+ # Only include params that actually exist in the DB schema
446
+ existing_params = [p for p in sub_params if p in class_pdefs]
447
+ spec._raw_params = existing_params # type: ignore[attr-defined]
448
+ spec._primary_class = entity_class # type: ignore[attr-defined]
449
+ spec._all_param_defs = class_pdefs # type: ignore[attr-defined]
450
+ specs.append(spec)
451
+
452
+ handled_classes.add(entity_class)
453
+
454
+ # ---- 2b. Create solve_period_period sheet for nested Map params ----
455
+ # Look for a spec named 'solve_period' and clone it for nested Maps
456
+ for spec in list(specs):
457
+ if spec.sheet_name == "solve_period":
458
+ nested_spec = SheetSpec(
459
+ sheet_name="solve_period_period",
460
+ layout="nested_periodic",
461
+ entity_classes=list(spec.entity_classes),
462
+ entity_columns=list(spec.entity_columns),
463
+ has_entity_alternative=False,
464
+ )
465
+ raw = getattr(spec, "_raw_params", None)
466
+ if raw is not None:
467
+ nested_spec._raw_params = list(raw) # type: ignore[attr-defined]
468
+ pclass = getattr(spec, "_primary_class", None)
469
+ if pclass is not None:
470
+ nested_spec._primary_class = pclass # type: ignore[attr-defined]
471
+ all_pdefs = getattr(spec, "_all_param_defs", None)
472
+ if all_pdefs is not None:
473
+ nested_spec._all_param_defs = dict(all_pdefs) # type: ignore[attr-defined]
474
+ specs.append(nested_spec)
475
+ break
476
+
477
+ # ---- 3. Process unpack_map_params rules ----
478
+ for group_name, rule in unpack_rules.items():
479
+ source_classes: list[str] = rule["source_classes"]
480
+ unpack_params: list[str] = rule["params"]
481
+ index_column: str = rule["index_column"]
482
+ direction_col_name: str | None = rule.get("direction_column")
483
+
484
+ # Get entity_columns from first source class
485
+ dims = _get_dimension_names(db_contents, source_classes[0])
486
+ entity_cols = list(dims) if dims else [source_classes[0]]
487
+
488
+ # Build direction_map if source classes are merged
489
+ dir_map: dict[str, str] = {}
490
+ if direction_col_name:
491
+ for _mg_name, mg_rule in merge_rules.items():
492
+ for cls_name, dir_val in mg_rule["classes"].items():
493
+ if cls_name in source_classes:
494
+ dir_map[cls_name] = dir_val
495
+
496
+ # Collect param defs for descriptions
497
+ all_pdefs: dict[str, dict] = {}
498
+ for src_cls in source_classes:
499
+ for pdef in db_contents.parameter_definitions.get(src_cls, []):
500
+ if pdef["name"] in unpack_params:
501
+ all_pdefs[pdef["name"]] = pdef
502
+
503
+ sheet_nm = f"{group_name}_c"
504
+ spec = SheetSpec(
505
+ sheet_name=sheet_nm,
506
+ layout="constant",
507
+ entity_classes=source_classes,
508
+ entity_columns=entity_cols,
509
+ parameter_names=unpack_params,
510
+ direction_column=direction_col_name,
511
+ direction_map=dir_map,
512
+ unpack_index_column=index_column,
513
+ has_entity_alternative=False,
514
+ )
515
+ spec._all_param_defs = all_pdefs # type: ignore[attr-defined]
516
+ specs.append(spec)
517
+
518
+ # ---- 4. Process remaining entity classes (generic path) ----
519
+ for ec in db_contents.entity_classes:
520
+ cls_name = ec["name"]
521
+ if cls_name in handled_classes:
522
+ continue
523
+ if cls_name in merged_classes:
524
+ continue
525
+ if cls_name in split_classes:
526
+ continue
527
+
528
+ dims = ec["dimension_name_list"]
529
+ entity_cols = list(dims) if dims else [cls_name]
530
+ pdefs = db_contents.parameter_definitions.get(cls_name, [])
531
+
532
+ # Filter out params that went to unpack rules
533
+ unpacked = unpacked_params_per_class.get(cls_name, set())
534
+ remaining_pdefs = [p for p in pdefs if p["name"] not in unpacked]
535
+ remaining_param_names = [p["name"] for p in remaining_pdefs]
536
+ remaining_param_defs = {p["name"]: p for p in remaining_pdefs}
537
+
538
+ base_name = _make_base_name(cls_name, drop_dims)
539
+
540
+ if not remaining_param_names:
541
+ # No params remaining: either link sheet or skip
542
+ if not dims:
543
+ # Zero-dim class with no params — skip (upDown, reserve, etc.)
544
+ continue
545
+ # Multi-dim relationship class -> link sheet
546
+ spec = SheetSpec(
547
+ sheet_name=base_name,
548
+ layout="link",
549
+ entity_classes=[cls_name],
550
+ entity_columns=entity_cols,
551
+ has_entity_alternative=False,
552
+ )
553
+ specs.append(spec)
554
+ continue
555
+
556
+ # Has parameters: classify by layout
557
+ layout_params: dict[str, list[str]] = {
558
+ "constant": [],
559
+ "periodic": [],
560
+ "timeseries": [],
561
+ "stochastic": [],
562
+ "ladder": [],
563
+ }
564
+ for pdef in remaining_pdefs:
565
+ pname = pdef["name"]
566
+ types = classify_param_types(
567
+ pdef.get("parameter_type_list"),
568
+ entity_class=cls_name,
569
+ param_name=pname,
570
+ )
571
+ if "ladder" in types:
572
+ # Facet-leaf params (price_ladder_*) get their own
573
+ # dedicated sheet per param; never co-occur with
574
+ # constant/periodic/timeseries/stochastic.
575
+ layout_params["ladder"].append(pname)
576
+ continue
577
+ if "constant" in types:
578
+ layout_params["constant"].append(pname)
579
+ if "periodic" in types:
580
+ layout_params["periodic"].append(pname)
581
+ if "timeseries" in types:
582
+ layout_params["timeseries"].append(pname)
583
+ if "stochastic" in types:
584
+ layout_params["stochastic"].append(pname)
585
+ # For params with periodic capability but no 3d_map: check actual DB values
586
+ if "periodic" in types and "timeseries" not in types:
587
+ if _has_time_indexed_values(db_contents, cls_name, pname):
588
+ layout_params["timeseries"].append(pname)
589
+
590
+ has_layouts = [k for k in ("constant", "periodic", "timeseries") if layout_params[k]]
591
+
592
+ if not has_layouts:
593
+ # Fallback: treat as constant
594
+ has_layouts = ["constant"]
595
+ layout_params["constant"] = remaining_param_names
596
+
597
+ # Apply rename_sheets
598
+ display_name = rename_sheets.get(base_name, base_name)
599
+
600
+ use_suffix = len(has_layouts) > 1
601
+ layout_to_suffix = {"constant": "_c", "periodic": "_p", "timeseries": "_t"}
602
+
603
+ for layout_key in ("constant", "periodic", "timeseries"):
604
+ params = layout_params[layout_key]
605
+ if not params:
606
+ continue
607
+
608
+ suffix = layout_to_suffix[layout_key] if use_suffix else ""
609
+ # When only one layout, check sheet_order for suffix convention
610
+ if not use_suffix:
611
+ candidate = f"{display_name}{layout_to_suffix[layout_key]}"
612
+ if _sheet_name_in_order(candidate, sheet_order):
613
+ suffix = layout_to_suffix[layout_key]
614
+
615
+ sheet_nm = f"{display_name}{suffix}"
616
+ has_ea = layout_key == "constant" and cls_name in ea_classes
617
+
618
+ spec = SheetSpec(
619
+ sheet_name=sheet_nm,
620
+ layout=layout_key,
621
+ entity_classes=[cls_name],
622
+ entity_columns=entity_cols,
623
+ has_entity_alternative=has_ea,
624
+ )
625
+
626
+ # Apply add_entity_elements rules (only to constant sheets)
627
+ if cls_name in element_rules and layout_key == "constant":
628
+ erule = element_rules[cls_name]
629
+ spec.extra_entity_columns = erule["columns"]
630
+ spec.extra_entity_class = erule["from_class"]
631
+
632
+ # Mark params that also have a constant sibling sheet so the
633
+ # periodic writer skips their scalar-only rows (avoids the
634
+ # node.existing-style round-trip duplication).
635
+ if layout_key == "periodic":
636
+ spec.scalar_params_on_constant_sibling = (
637
+ set(params) & set(layout_params["constant"])
638
+ )
639
+
640
+ spec._raw_params = params # type: ignore[attr-defined]
641
+ spec._primary_class = cls_name # type: ignore[attr-defined]
642
+ spec._all_param_defs = remaining_param_defs # type: ignore[attr-defined]
643
+ specs.append(spec)
644
+
645
+ # Stochastic sheets: one per parameter
646
+ # Use _s when only one stochastic param, _s_paramname when multiple
647
+ n_stoch = len(layout_params["stochastic"])
648
+ for stoch_pname in layout_params["stochastic"]:
649
+ if n_stoch == 1:
650
+ sheet_nm = f"{display_name}_s"
651
+ else:
652
+ sheet_nm = f"{display_name}_s_{stoch_pname}"
653
+ stoch_spec = SheetSpec(
654
+ sheet_name=sheet_nm,
655
+ layout="stochastic",
656
+ entity_classes=[cls_name],
657
+ entity_columns=entity_cols,
658
+ has_entity_alternative=False,
659
+ )
660
+ stoch_spec._raw_params = [stoch_pname] # type: ignore[attr-defined]
661
+ stoch_spec._primary_class = cls_name # type: ignore[attr-defined]
662
+ stoch_spec._all_param_defs = remaining_param_defs # type: ignore[attr-defined]
663
+ specs.append(stoch_spec)
664
+
665
+ # Ladder sheets: one per facet-leaf param. Sheet name is the
666
+ # DB param name verbatim (e.g. ``price_ladder_cumulative``) — the
667
+ # ``ladder`` writer renders the price/quantity facet as two real
668
+ # Excel parameter columns sharing the entity/tier/[period] index
669
+ # columns.
670
+ for ladder_pname in layout_params["ladder"]:
671
+ ladder_spec = SheetSpec(
672
+ sheet_name=ladder_pname,
673
+ layout="ladder",
674
+ entity_classes=[cls_name],
675
+ entity_columns=entity_cols,
676
+ has_entity_alternative=False,
677
+ )
678
+ ladder_spec._raw_params = [ladder_pname] # type: ignore[attr-defined]
679
+ ladder_spec._primary_class = cls_name # type: ignore[attr-defined]
680
+ ladder_spec._all_param_defs = remaining_param_defs # type: ignore[attr-defined]
681
+ specs.append(ladder_spec)
682
+
683
+ # ---- 5. Add special sheets ----
684
+ specs.append(SheetSpec(
685
+ sheet_name="scenario",
686
+ layout="scenario",
687
+ entity_classes=[],
688
+ entity_columns=[],
689
+ has_entity_alternative=False,
690
+ ))
691
+ specs.append(SheetSpec(
692
+ sheet_name="navigate",
693
+ layout="navigate",
694
+ entity_classes=[],
695
+ entity_columns=[],
696
+ has_entity_alternative=False,
697
+ ))
698
+ specs.append(SheetSpec(
699
+ sheet_name="version",
700
+ layout="version",
701
+ entity_classes=[],
702
+ entity_columns=[],
703
+ has_entity_alternative=False,
704
+ ))
705
+
706
+ # ---- 6. Populate parameter_names using order_parameters ----
707
+ for spec in specs:
708
+ raw_params: list[str] | None = getattr(spec, "_raw_params", None)
709
+ if raw_params is None:
710
+ continue
711
+ primary_class: str = getattr(spec, "_primary_class", "")
712
+ spec.parameter_names = order_parameters(raw_params, primary_class, db_contents)
713
+
714
+ # Move pre-EA params out of parameter_names into pre_ea_params
715
+ if spec.has_entity_alternative and spec.layout == "constant":
716
+ for cls in spec.entity_classes:
717
+ pre_ea = pre_ea_rules.get(cls, [])
718
+ if pre_ea:
719
+ spec.pre_ea_params = [p for p in pre_ea if p in spec.parameter_names]
720
+ spec.parameter_names = [
721
+ p for p in spec.parameter_names if p not in spec.pre_ea_params
722
+ ]
723
+ break
724
+
725
+ # ---- 7. Populate descriptions ----
726
+ for spec in specs:
727
+ all_pdefs_attr: dict[str, dict] | None = getattr(spec, "_all_param_defs", None)
728
+ if all_pdefs_attr is None:
729
+ continue
730
+ for pname in list(spec.parameter_names) + spec.pre_ea_params:
731
+ pdef = all_pdefs_attr.get(pname)
732
+ if pdef and pdef.get("description"):
733
+ spec.descriptions[pname] = pdef["description"]
734
+
735
+ # ---- 7b. Set index_name_default from layout type ----
736
+ for spec in specs:
737
+ default = index_name_defaults.get(spec.layout)
738
+ if default:
739
+ spec.index_name_default = default
740
+
741
+ # ---- 8. Clean up temporary attributes ----
742
+ for spec in specs:
743
+ for attr in ("_raw_params", "_primary_class", "_all_param_defs"):
744
+ if hasattr(spec, attr):
745
+ delattr(spec, attr)
746
+
747
+ # ---- 9. Sort by sheet_order ----
748
+ order_map = {name: i for i, name in enumerate(sheet_order)}
749
+ max_order = len(sheet_order)
750
+
751
+ def sort_key(spec: SheetSpec) -> tuple[int, str]:
752
+ if spec.sheet_name in order_map:
753
+ return (order_map[spec.sheet_name], spec.sheet_name)
754
+ return (max_order, spec.sheet_name)
755
+
756
+ specs.sort(key=sort_key)
757
+
758
+ # ---- 10. Optional parameter-group filter ----
759
+ if include_groups is not None:
760
+ always_include_cfg: dict[str, list[str]] = settings.get(
761
+ "always_include_with_groups", {}
762
+ ) or {}
763
+ specs = _filter_specs_by_groups(
764
+ specs, db_contents, include_groups, always_include_cfg,
765
+ )
766
+
767
+ return specs
768
+
769
+
770
+ def _filter_specs_by_groups(
771
+ specs: list[SheetSpec],
772
+ db_contents: DatabaseContents,
773
+ include_groups: set[str],
774
+ always_include_with_groups: dict[str, list[str]],
775
+ ) -> list[SheetSpec]:
776
+ """Filter specs so only parameters belonging to *include_groups* survive.
777
+
778
+ - Drops names from ``parameter_names`` and ``pre_ea_params`` whose
779
+ ``(class, name)`` group (per ``db_contents.param_to_group``) is not
780
+ in ``include_groups``. For multi-class specs (merge / unpack), a
781
+ column is kept if ANY class places it in an allowed group.
782
+ - Drops any data-layout spec that ends up with no surviving columns,
783
+ unless its sheet name is listed under a selected group in
784
+ ``always_include_with_groups``.
785
+ - Keeps ``navigate``, ``scenario``, ``version`` untouched.
786
+ - Keeps ``link`` sheets only when every dimension class still owns a
787
+ retained data sheet (or the link sheet itself is force-kept).
788
+ """
789
+ always_include: set[str] = set()
790
+ for g in include_groups:
791
+ for sheet_name in always_include_with_groups.get(g, []):
792
+ always_include.add(sheet_name)
793
+
794
+ def _allowed(class_names: list[str], param_name: str) -> bool:
795
+ for c in class_names:
796
+ g = db_contents.param_to_group.get((c, param_name))
797
+ if g is not None and g in include_groups:
798
+ return True
799
+ return False
800
+
801
+ data_layouts = {
802
+ "constant", "periodic", "timeseries", "stochastic",
803
+ "nested_periodic", "ladder",
804
+ }
805
+ structural_layouts = {"navigate", "scenario", "version"}
806
+
807
+ # Pass 1: filter data-layout specs; defer link sheets to pass 2.
808
+ kept: list[SheetSpec] = []
809
+ deferred_links: list[SheetSpec] = []
810
+ retained_classes: set[str] = set()
811
+
812
+ for spec in specs:
813
+ if spec.layout in structural_layouts:
814
+ kept.append(spec)
815
+ continue
816
+ if spec.layout == "link":
817
+ deferred_links.append(spec)
818
+ continue
819
+ if spec.layout in data_layouts:
820
+ spec.parameter_names = [
821
+ p for p in spec.parameter_names
822
+ if _allowed(spec.entity_classes, p)
823
+ ]
824
+ spec.pre_ea_params = [
825
+ p for p in spec.pre_ea_params
826
+ if _allowed(spec.entity_classes, p)
827
+ ]
828
+ if (
829
+ spec.parameter_names
830
+ or spec.pre_ea_params
831
+ or spec.sheet_name in always_include
832
+ ):
833
+ kept.append(spec)
834
+ retained_classes.update(spec.entity_classes)
835
+ continue
836
+ # Unknown layouts: keep as-is (defensive).
837
+ kept.append(spec)
838
+
839
+ # Pass 2: link sheets are kept when:
840
+ # - the sheet is in always_include, or
841
+ # - the link's own entity class has parameter definitions in the
842
+ # schema AND those produced a retained data sheet (i.e. the
843
+ # class is in retained_classes), or
844
+ # - the link's own entity class has no parameter definitions at
845
+ # all (pure topology) AND every dimension class still has a
846
+ # retained data sheet.
847
+ #
848
+ # The first sub-rule is what drops e.g. ``connection_node`` when its
849
+ # only schema parameters live in a non-selected group such as
850
+ # ``constraint``. The third covers pure relationships like
851
+ # ``commodity_node`` and ``group_node`` — they survive while their
852
+ # endpoint classes own data, and disappear together with the
853
+ # endpoint when the endpoint's params are all filtered out.
854
+ class_dims: dict[str, tuple] = {
855
+ ec["name"]: ec["dimension_name_list"] for ec in db_contents.entity_classes
856
+ }
857
+ for spec in deferred_links:
858
+ if spec.sheet_name in always_include:
859
+ kept.append(spec)
860
+ continue
861
+ cls = spec.entity_classes[0] if spec.entity_classes else None
862
+ if cls is None:
863
+ continue
864
+ has_own_params = bool(db_contents.parameter_definitions.get(cls))
865
+ if has_own_params:
866
+ if cls not in retained_classes:
867
+ continue
868
+ else:
869
+ dims = class_dims.get(cls, ())
870
+ if not dims or not all(d in retained_classes for d in dims):
871
+ continue
872
+ kept.append(spec)
873
+
874
+ # Preserve the prior sort order from build_sheet_specs (specs was already
875
+ # sorted before this filter ran). Re-running the same key keeps it stable.
876
+ return kept