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,3559 @@
1
+ """Write FlexTool data to Excel sheets based on SheetSpec configurations.
2
+
3
+ Each ``write_*`` function populates one openpyxl worksheet according to
4
+ its layout type (constant, periodic, timeseries, link, scenario, version,
5
+ navigate).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from openpyxl.styles import Font, PatternFill
14
+ from openpyxl.styles.colors import Color
15
+ from openpyxl.worksheet.worksheet import Worksheet
16
+
17
+ from spinedb_api import Map, Array
18
+
19
+ from flextool.export_to_tabular.db_reader import DatabaseContents
20
+
21
+ from flextool.export_to_tabular.formatting import (
22
+ add_navigate_link,
23
+ auto_column_width,
24
+ format_constant_sheet,
25
+ format_constant_sheet_v2,
26
+ format_link_sheet,
27
+ format_link_sheet_v2,
28
+ format_periodic_sheet,
29
+ format_periodic_sheet_v2,
30
+ format_timeseries_sheet,
31
+ format_timeseries_sheet_v2,
32
+ FILL_DEF_COL,
33
+ FILL_ENTITY_HEADER,
34
+ FILL_INDEX_DATA,
35
+ FILL_INDEX_HEADER,
36
+ FILL_DESC_ROW,
37
+ FILL_DESC_DATA,
38
+ FILL_PARAM_HEADER,
39
+ FONT_DESC_ROW,
40
+ FONT_DESC_DATA,
41
+ FONT_NAVIGATE_LINK,
42
+ )
43
+ from flextool.export_to_tabular.sheet_config import SheetSpec
44
+
45
+ # Index-cell marker written for a zero-length Array/Map parameter value so
46
+ # the round-trip can rebuild an *empty* collection rather than dropping it
47
+ # (a positional layout emits no rows for a collection with no elements).
48
+ # The reader side mirrors this literal in
49
+ # write_self_describing_to_db._group_map_records — keep the two in sync.
50
+ EMPTY_COLLECTION_SENTINEL = "(empty)"
51
+
52
+
53
+ def _to_native(value: Any) -> Any:
54
+ """Convert numpy scalar types to native Python types for openpyxl."""
55
+ if isinstance(value, np.floating):
56
+ return float(value)
57
+ if isinstance(value, np.integer):
58
+ return int(value)
59
+ if isinstance(value, (np.str_, np.bytes_)):
60
+ return str(value)
61
+ if isinstance(value, np.bool_):
62
+ return bool(value)
63
+ return value
64
+
65
+
66
+ def _array_to_str(arr: Array) -> str:
67
+ """Convert an Array value to a comma-separated string."""
68
+ return ", ".join(str(_to_native(v)) for v in arr.values)
69
+
70
+
71
+ def _is_map(value: Any) -> bool:
72
+ """Check if a value is a Map."""
73
+ return isinstance(value, Map)
74
+
75
+
76
+ def _is_array(value: Any) -> bool:
77
+ """Check if a value is an Array."""
78
+ return isinstance(value, Array)
79
+
80
+
81
+ def _is_scalar(value: Any) -> bool:
82
+ """Check if a value is a scalar (float, int, str) — not Map or Array."""
83
+ return isinstance(value, (int, float, str, np.floating, np.integer, np.str_))
84
+
85
+
86
+ class IndexClassifier:
87
+ """Classifies Map index values as time-indexed or period-indexed.
88
+
89
+ Built once from DatabaseContents by collecting known timestep names
90
+ (from timeline entities) and period names (from solve entities).
91
+ """
92
+
93
+ def __init__(self, db_contents: DatabaseContents) -> None:
94
+ self.time_indexes: set[str] = set()
95
+ self.period_indexes: set[str] = set()
96
+
97
+ for (cls, _byname, pname, _alt), val in db_contents.parameter_values.items():
98
+ if not isinstance(val, Map):
99
+ continue
100
+ # Timeline timestep_duration maps contain all timestep names as indexes
101
+ if cls == "timeline" and pname == "timestep_duration":
102
+ self.time_indexes.update(str(_to_native(idx)) for idx in val.indexes)
103
+ # Solve parameters with Map values use period names as indexes
104
+ if cls == "solve" and pname != "solver_arguments":
105
+ self.period_indexes.update(str(_to_native(idx)) for idx in val.indexes)
106
+
107
+ def is_time_indexed(self, value: Any) -> bool:
108
+ """Check if a Map value contains time-series data (should go on _t sheet).
109
+
110
+ Classification order:
111
+ 1. Explicit index_name == "time" → True
112
+ 2. Explicit index_name == "period" → False
113
+ 3. Cross-reference indexes with known timesteps/periods from the DB
114
+ 4. Fallback: large index count (>100) suggests time-series
115
+ """
116
+ if not isinstance(value, Map) or len(value.indexes) == 0:
117
+ return False
118
+ iname = getattr(value, "index_name", "")
119
+ if iname == "time":
120
+ return True
121
+ if iname == "period":
122
+ return False
123
+
124
+ # Cross-reference with known indexes
125
+ map_idxs = {str(_to_native(idx)) for idx in value.indexes}
126
+ has_time_overlap = bool(map_idxs & self.time_indexes)
127
+ has_period_overlap = bool(map_idxs & self.period_indexes)
128
+
129
+ if has_time_overlap and not has_period_overlap:
130
+ return True
131
+ if has_period_overlap and not has_time_overlap:
132
+ return False
133
+
134
+ # Both or neither overlap — use count as last resort
135
+ return len(map_idxs) > 100
136
+
137
+
138
+ # Module-level classifier instance, set by export_to_excel before writing sheets
139
+ _index_classifier: IndexClassifier | None = None
140
+
141
+
142
+ def _is_time_indexed_map(value: Any) -> bool:
143
+ """Check if a Map value contains time-series data (should go on _t sheet, not _p)."""
144
+ if _index_classifier is not None:
145
+ return _index_classifier.is_time_indexed(value)
146
+ # Fallback if classifier not initialized
147
+ if not isinstance(value, Map) or len(value.indexes) == 0:
148
+ return False
149
+ return getattr(value, "index_name", "") == "time"
150
+
151
+
152
+ def _get_extra_columns_for_entity(
153
+ entity_byname: tuple,
154
+ entity_class_name: str,
155
+ spec: SheetSpec,
156
+ db_contents: DatabaseContents,
157
+ ) -> list[str | None]:
158
+ """Look up extra entity column values for a given entity.
159
+
160
+ For example, for connection_c with extra_entity_class='connection__node__node',
161
+ finds the matching multi-dim entity and extracts the extra dimension values.
162
+
163
+ Returns a list of values for each extra_entity_column, or Nones if not found.
164
+ """
165
+ if not spec.extra_entity_columns or not spec.extra_entity_class:
166
+ return []
167
+
168
+ extra_entities = db_contents.entities.get(spec.extra_entity_class, [])
169
+ # The main entity name is the first element (e.g. 'ConnBat' for connection)
170
+ main_entity_name = entity_byname[0] if entity_byname else None
171
+
172
+ # Find the extra entity that has this main entity as its first element
173
+ for extra_ent in extra_entities:
174
+ extra_byname = extra_ent["entity_byname"]
175
+ if extra_byname[0] == main_entity_name:
176
+ # Return the extra dimensions (skip the first which is the main entity)
177
+ extra_vals = [str(_to_native(v)) for v in extra_byname[1:]]
178
+ # Pad or truncate to match extra_entity_columns length
179
+ while len(extra_vals) < len(spec.extra_entity_columns):
180
+ extra_vals.append(None)
181
+ return extra_vals[: len(spec.extra_entity_columns)]
182
+
183
+ return [None] * len(spec.extra_entity_columns)
184
+
185
+
186
+ # Pre-built index for fast alternative lookups, set by export_to_excel.
187
+ _entity_alts_index: dict[tuple[str, tuple], set[str]] = {}
188
+
189
+
190
+ def _build_entity_alts_index(db_contents: DatabaseContents) -> None:
191
+ """Build a lookup from (class, byname) → set of alternatives."""
192
+ global _entity_alts_index
193
+ _entity_alts_index = {}
194
+ for (cls, byname, _param, alt) in db_contents.parameter_values:
195
+ key = (cls, byname)
196
+ if key not in _entity_alts_index:
197
+ _entity_alts_index[key] = set()
198
+ _entity_alts_index[key].add(alt)
199
+ for (cls, byname, alt) in db_contents.entity_alternatives:
200
+ key = (cls, byname)
201
+ if key not in _entity_alts_index:
202
+ _entity_alts_index[key] = set()
203
+ _entity_alts_index[key].add(alt)
204
+
205
+
206
+ def _find_alternatives_for_entity(
207
+ entity_class: str,
208
+ entity_byname: tuple,
209
+ spec: SheetSpec,
210
+ db_contents: DatabaseContents,
211
+ ) -> list[str]:
212
+ """Find all alternatives that have parameter values or entity_alternatives for an entity.
213
+
214
+ If the entity has no data at all, returns ``[""]`` so that it still
215
+ gets written as an empty row (preserving the entity in the export).
216
+ """
217
+ key = (entity_class, entity_byname)
218
+ alts = _entity_alts_index.get(key, set())
219
+
220
+ if not alts:
221
+ return [""] # empty alt → entity row with no data
222
+
223
+ return sorted(alts)
224
+
225
+
226
+ def _get_direction(entity_class: str, spec: SheetSpec) -> str | None:
227
+ """Get the direction value for an entity class from the spec's direction_map."""
228
+ if spec.direction_column and spec.direction_map:
229
+ return spec.direction_map.get(entity_class)
230
+ return None
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Constant sheet
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ def write_constant_sheet(
239
+ ws: Worksheet,
240
+ spec: SheetSpec,
241
+ db_contents: DatabaseContents,
242
+ ) -> None:
243
+ """Write a constant-layout sheet."""
244
+ # Build header columns
245
+ headers: list[str] = ["alternative"]
246
+ headers.extend(spec.entity_columns)
247
+ headers.extend(spec.extra_entity_columns)
248
+ # Pre-EA params (e.g. node_type for node_c) go before Entity Alternative
249
+ if spec.pre_ea_params:
250
+ headers.extend(spec.pre_ea_params)
251
+ if spec.has_entity_alternative:
252
+ headers.append("Entity Alternative")
253
+ if spec.direction_column:
254
+ headers.append(spec.direction_column)
255
+ if spec.unpack_index_column:
256
+ headers.append(spec.unpack_index_column)
257
+
258
+ # Parameter columns (pre_ea_params already in headers, not repeated here)
259
+ param_headers: list[str] = list(spec.parameter_names)
260
+
261
+ all_headers = headers + param_headers
262
+
263
+ # --- Row 1: descriptions ---
264
+ ws.cell(row=1, column=1, value="navigate")
265
+ len(headers)
266
+ for col_idx, hdr in enumerate(all_headers, start=1):
267
+ if hdr == "Entity Alternative":
268
+ ws.cell(row=1, column=col_idx, value="Whether the entity is active in this alternative.")
269
+ elif hdr in spec.descriptions:
270
+ ws.cell(row=1, column=col_idx, value=spec.descriptions[hdr])
271
+
272
+ # --- Row 2: headers ---
273
+ for col_idx, hdr in enumerate(all_headers, start=1):
274
+ ws.cell(row=2, column=col_idx, value=hdr)
275
+
276
+ # --- Collect data rows ---
277
+ data_rows: list[list[Any]] = []
278
+
279
+ if spec.unpack_index_column:
280
+ _collect_unpack_rows(data_rows, spec, db_contents, headers, param_headers)
281
+ else:
282
+ _collect_constant_rows(data_rows, spec, db_contents, headers, param_headers)
283
+
284
+ # --- Sort rows ---
285
+ data_rows.sort(key=lambda r: tuple(str(v) if v is not None else "" for v in r))
286
+
287
+ # --- Write data rows ---
288
+ for row_idx, row_data in enumerate(data_rows, start=3):
289
+ for col_idx, value in enumerate(row_data, start=1):
290
+ if value is not None:
291
+ ws.cell(row=row_idx, column=col_idx, value=value)
292
+
293
+ # --- Formatting ---
294
+ n_entity_cols = len(spec.entity_columns)
295
+ n_extra = len(spec.extra_entity_columns) + len(spec.pre_ea_params)
296
+ if spec.has_entity_alternative:
297
+ n_extra += 1
298
+ if spec.direction_column:
299
+ n_extra += 1
300
+ if spec.unpack_index_column:
301
+ n_extra += 1
302
+
303
+ format_constant_sheet(ws, n_entity_cols, n_extra, has_entity_alt=False)
304
+ add_navigate_link(ws)
305
+ auto_column_width(ws)
306
+
307
+
308
+ def _collect_constant_rows(
309
+ data_rows: list[list[Any]],
310
+ spec: SheetSpec,
311
+ db_contents: DatabaseContents,
312
+ headers: list[str],
313
+ param_headers: list[str],
314
+ ) -> None:
315
+ """Collect rows for a regular (non-unpack) constant sheet."""
316
+ for entity_class in spec.entity_classes:
317
+ entities = db_contents.entities.get(entity_class, [])
318
+ direction = _get_direction(entity_class, spec)
319
+
320
+ for entity in entities:
321
+ entity_byname = entity["entity_byname"]
322
+ alts = _find_alternatives_for_entity(
323
+ entity_class, entity_byname, spec, db_contents
324
+ )
325
+
326
+ for alt in alts:
327
+ row: list[Any] = [alt]
328
+
329
+ # Entity elements
330
+ for elem in entity_byname:
331
+ row.append(str(_to_native(elem)))
332
+
333
+ # Extra entity columns
334
+ if spec.extra_entity_columns:
335
+ extras = _get_extra_columns_for_entity(
336
+ entity_byname, entity_class, spec, db_contents
337
+ )
338
+ row.extend(extras)
339
+
340
+ # Pre-EA params (e.g. node_type for node_c)
341
+ for pname in spec.pre_ea_params:
342
+ key = (entity_class, entity_byname, pname, alt)
343
+ value = db_contents.parameter_values.get(key)
344
+ if value is not None and _is_scalar(value):
345
+ row.append(_to_native(value))
346
+ else:
347
+ row.append(None)
348
+
349
+ # Entity Alternative (after entity cols + pre-EA, before direction)
350
+ if spec.has_entity_alternative:
351
+ ea_key = (entity_class, entity_byname, alt)
352
+ ea_val = db_contents.entity_alternatives.get(ea_key)
353
+ row.append(ea_val if ea_val is not None else None)
354
+
355
+ # Direction
356
+ if spec.direction_column:
357
+ row.append(direction)
358
+
359
+ # Parameter values — check for Arrays that need expansion
360
+ param_values: list[Any] = []
361
+ has_array = False
362
+ array_param_idx: int | None = None
363
+ array_elements: list[Any] = []
364
+
365
+ for pi, pname in enumerate(spec.parameter_names):
366
+ key = (entity_class, entity_byname, pname, alt)
367
+ value = db_contents.parameter_values.get(key)
368
+
369
+ if value is None:
370
+ param_values.append(None)
371
+ elif _is_map(value):
372
+ # Maps go on _p or _t sheets, not constant
373
+ param_values.append(None)
374
+ elif _is_array(value):
375
+ has_array = True
376
+ array_param_idx = pi
377
+ array_elements = [_to_native(v) for v in value.values]
378
+ param_values.append(None) # placeholder
379
+ elif _is_scalar(value):
380
+ param_values.append(_to_native(value))
381
+ else:
382
+ param_values.append(None)
383
+
384
+ if has_array and array_elements:
385
+ # Expand: one row per array element
386
+ for elem in array_elements:
387
+ expanded_row = list(row)
388
+ expanded_params = list(param_values)
389
+ expanded_params[array_param_idx] = elem
390
+ expanded_row.extend(expanded_params)
391
+ data_rows.append(expanded_row)
392
+ else:
393
+ row.extend(param_values)
394
+ data_rows.append(row)
395
+
396
+
397
+ def _collect_unpack_rows(
398
+ data_rows: list[list[Any]],
399
+ spec: SheetSpec,
400
+ db_contents: DatabaseContents,
401
+ headers: list[str],
402
+ param_headers: list[str],
403
+ ) -> None:
404
+ """Collect rows for an unpacked-Map constant sheet (e.g. constraint coefficients)."""
405
+ for entity_class in spec.entity_classes:
406
+ entities = db_contents.entities.get(entity_class, [])
407
+ direction = _get_direction(entity_class, spec)
408
+
409
+ for entity in entities:
410
+ entity_byname = entity["entity_byname"]
411
+ alts = _find_alternatives_for_entity(
412
+ entity_class, entity_byname, spec, db_contents
413
+ )
414
+
415
+ for alt in alts:
416
+ # Collect all map indexes across all unpack params
417
+ all_indexes: set[str] = set()
418
+ param_maps: dict[str, Map] = {}
419
+
420
+ for pname in spec.parameter_names:
421
+ key = (entity_class, entity_byname, pname, alt)
422
+ value = db_contents.parameter_values.get(key)
423
+ if value is not None and _is_map(value):
424
+ param_maps[pname] = value
425
+ for idx in value.indexes:
426
+ all_indexes.add(str(_to_native(idx)))
427
+
428
+ if not all_indexes:
429
+ continue
430
+
431
+ for idx_val in sorted(all_indexes):
432
+ row: list[Any] = [alt]
433
+
434
+ # Entity elements
435
+ for elem in entity_byname:
436
+ row.append(str(_to_native(elem)))
437
+
438
+ # Extra entity columns
439
+ if spec.extra_entity_columns:
440
+ extras = _get_extra_columns_for_entity(
441
+ entity_byname, entity_class, spec, db_contents
442
+ )
443
+ row.extend(extras)
444
+
445
+ # Pre-EA params
446
+ for pre_pname in spec.pre_ea_params:
447
+ pre_key = (entity_class, entity_byname, pre_pname, alt)
448
+ pre_val = db_contents.parameter_values.get(pre_key)
449
+ if pre_val is not None and _is_scalar(pre_val):
450
+ row.append(_to_native(pre_val))
451
+ else:
452
+ row.append(None)
453
+
454
+ # Entity Alternative (after entity cols + pre-EA, before direction)
455
+ if spec.has_entity_alternative:
456
+ ea_key = (entity_class, entity_byname, alt)
457
+ ea_val = db_contents.entity_alternatives.get(ea_key)
458
+ row.append(ea_val if ea_val is not None else None)
459
+
460
+ # Direction
461
+ if spec.direction_column:
462
+ row.append(direction)
463
+
464
+ # Unpack index column
465
+ row.append(idx_val)
466
+
467
+ # Parameter values
468
+ for pname in spec.parameter_names:
469
+ m = param_maps.get(pname)
470
+ if m is not None:
471
+ # Find the value at this index
472
+ found = False
473
+ for mi, mv in zip(m.indexes, m.values):
474
+ if str(_to_native(mi)) == idx_val:
475
+ row.append(_to_native(mv))
476
+ found = True
477
+ break
478
+ if not found:
479
+ row.append(None)
480
+ else:
481
+ row.append(None)
482
+
483
+ data_rows.append(row)
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Periodic sheet
488
+ # ---------------------------------------------------------------------------
489
+
490
+
491
+ def write_periodic_sheet(
492
+ ws: Worksheet,
493
+ spec: SheetSpec,
494
+ db_contents: DatabaseContents,
495
+ ) -> None:
496
+ """Write a periodic-layout sheet."""
497
+ # Determine index column name from Map values
498
+ index_col_name = _find_index_column_name(spec, db_contents, default="period")
499
+
500
+ # Build header columns
501
+ headers: list[str] = ["alternative"]
502
+ headers.extend(spec.entity_columns)
503
+ if spec.direction_column:
504
+ headers.append(spec.direction_column)
505
+ headers.append(index_col_name)
506
+
507
+ # Parameter columns (no Entity Alternative on periodic sheets)
508
+ all_headers = headers + list(spec.parameter_names)
509
+
510
+ # --- Row 1: descriptions ---
511
+ ws.cell(row=1, column=1, value="navigate")
512
+ n_fixed = len(headers)
513
+ for i, pname in enumerate(spec.parameter_names):
514
+ col_idx = n_fixed + 1 + i
515
+ if pname in spec.descriptions:
516
+ ws.cell(row=1, column=col_idx, value=spec.descriptions[pname])
517
+
518
+ # --- Row 2: headers ---
519
+ for col_idx, hdr in enumerate(all_headers, start=1):
520
+ ws.cell(row=2, column=col_idx, value=hdr)
521
+
522
+ # --- Collect data rows ---
523
+ data_rows: list[list[Any]] = []
524
+ len(headers) - 1 # 0-based position of index column
525
+
526
+ for entity_class in spec.entity_classes:
527
+ entities = db_contents.entities.get(entity_class, [])
528
+ direction = _get_direction(entity_class, spec)
529
+
530
+ for entity in entities:
531
+ entity_byname = entity["entity_byname"]
532
+ alts = _find_alternatives_for_entity(
533
+ entity_class, entity_byname, spec, db_contents
534
+ )
535
+
536
+ for alt in alts:
537
+ # Collect all index values and map/array data across params
538
+ all_indexes: set[str] = set()
539
+ param_maps: dict[str, Map] = {}
540
+ param_arrays: dict[str, list[str]] = {}
541
+
542
+ for pname in spec.parameter_names:
543
+ key = (entity_class, entity_byname, pname, alt)
544
+ value = db_contents.parameter_values.get(key)
545
+ if value is not None and _is_map(value):
546
+ # Check for nested Map (Map whose values are Maps)
547
+ is_nested = any(_is_map(mv) for mv in value.values)
548
+ if is_nested:
549
+ # Skip nested Maps — they go to solve_period_period
550
+ continue
551
+ # Skip time-indexed Maps — they belong on _t sheets
552
+ if _is_time_indexed_map(value):
553
+ continue
554
+ param_maps[pname] = value
555
+ for idx in value.indexes:
556
+ all_indexes.add(str(_to_native(idx)))
557
+ elif value is not None and _is_array(value):
558
+ # Array = boolean indicator (period names)
559
+ period_names = [str(_to_native(v)) for v in value.values]
560
+ param_arrays[pname] = period_names
561
+
562
+ if not all_indexes:
563
+ continue
564
+
565
+ for idx_val in sorted(all_indexes):
566
+ row: list[Any] = [alt]
567
+
568
+ # Entity elements
569
+ for elem in entity_byname:
570
+ row.append(str(_to_native(elem)))
571
+
572
+ # Direction
573
+ if spec.direction_column:
574
+ row.append(direction)
575
+
576
+ # Index column value
577
+ row.append(idx_val)
578
+
579
+ # Parameter values
580
+ for pname in spec.parameter_names:
581
+ m = param_maps.get(pname)
582
+ if m is not None:
583
+ found = False
584
+ for mi, mv in zip(m.indexes, m.values):
585
+ if str(_to_native(mi)) == idx_val:
586
+ row.append(_to_native(mv))
587
+ found = True
588
+ break
589
+ if not found:
590
+ row.append(None)
591
+ elif pname in param_arrays:
592
+ # Boolean: check if current period is in the array
593
+ if idx_val in param_arrays[pname]:
594
+ row.append(True)
595
+ else:
596
+ row.append(None)
597
+ else:
598
+ row.append(None)
599
+
600
+ data_rows.append(row)
601
+
602
+ # --- Sort rows ---
603
+ data_rows.sort(key=lambda r: tuple(str(v) if v is not None else "" for v in r))
604
+
605
+ # --- Write data rows ---
606
+ for row_idx, row_data in enumerate(data_rows, start=3):
607
+ for col_idx, value in enumerate(row_data, start=1):
608
+ if value is not None:
609
+ ws.cell(row=row_idx, column=col_idx, value=value)
610
+
611
+ # --- Formatting ---
612
+ n_entity_cols = len(spec.entity_columns)
613
+ n_extra = 0
614
+ if spec.direction_column:
615
+ n_extra += 1
616
+ n_extra += 1 # index column counts as extra
617
+
618
+ format_periodic_sheet(ws, n_entity_cols, n_extra)
619
+ add_navigate_link(ws)
620
+ auto_column_width(ws)
621
+
622
+
623
+ def _find_index_column_name(
624
+ spec: SheetSpec,
625
+ db_contents: DatabaseContents,
626
+ default: str = "period",
627
+ ) -> str:
628
+ """Determine the index column name by scanning Map values for index_name.
629
+
630
+ Ignores the placeholder 'x' from the DB and falls back to the spec's
631
+ ``index_name_default`` (from export_settings.yaml) or *default*.
632
+
633
+ Only maps that are actually written on THIS (periodic) sheet are
634
+ counted: nested Maps go to the nested-periodic sheet and time-indexed
635
+ Maps go to the ``_t`` sheet (except for time-structure classes), so
636
+ including them would let a param's stray time-series values out-vote
637
+ the genuine period index and mislabel the column ``index: time`` —
638
+ making every period-indexed value on the sheet round-trip with the
639
+ wrong axis name.
640
+ """
641
+ fallback = spec.index_name_default or default
642
+ name_counts: dict[str, int] = {}
643
+
644
+ for entity_class in spec.entity_classes:
645
+ for (cls, _byname, pname, _alt), value in db_contents.parameter_values.items():
646
+ if cls == entity_class and pname in spec.parameter_names and _is_map(value):
647
+ if any(_is_map(mv) for mv in value.values):
648
+ continue # nested Map → nested-periodic sheet
649
+ if _is_time_indexed_map(value) and entity_class not in _time_structure_classes:
650
+ continue # time-indexed → _t sheet
651
+ iname = value.index_name
652
+ if iname and iname != "x":
653
+ name_counts[iname] = name_counts.get(iname, 0) + 1
654
+
655
+ if name_counts:
656
+ return max(name_counts, key=name_counts.get) # type: ignore[arg-type]
657
+ return fallback
658
+
659
+
660
+ # ---------------------------------------------------------------------------
661
+ # Nested periodic sheet (solve_period_period)
662
+ # ---------------------------------------------------------------------------
663
+
664
+
665
+ def write_nested_periodic_sheet(
666
+ ws: Worksheet,
667
+ spec: SheetSpec,
668
+ db_contents: DatabaseContents,
669
+ ) -> None:
670
+ """Write a nested-periodic-layout sheet for nested Map parameters.
671
+
672
+ Handles parameters whose values are Map-of-Map (outer index =
673
+ current_solve_period, inner Map = periods_included -> 'yes').
674
+ """
675
+ # Build header columns
676
+ headers: list[str] = ["alternative"]
677
+ headers.extend(spec.entity_columns)
678
+ headers.append("current_solve_period")
679
+ headers.append("periods_included")
680
+
681
+ # Only include params that actually have nested Map values
682
+ nested_params: list[str] = []
683
+ for pname in spec.parameter_names:
684
+ for (cls, _byname, p, _alt), value in db_contents.parameter_values.items():
685
+ if cls in spec.entity_classes and p == pname and _is_map(value):
686
+ if any(_is_map(mv) for mv in value.values):
687
+ nested_params.append(pname)
688
+ break
689
+
690
+ if not nested_params:
691
+ # No nested data — write minimal headers
692
+ ws.cell(row=1, column=1, value="navigate")
693
+ all_headers = headers + list(spec.parameter_names)
694
+ for col_idx, hdr in enumerate(all_headers, start=1):
695
+ ws.cell(row=2, column=col_idx, value=hdr)
696
+ add_navigate_link(ws)
697
+ auto_column_width(ws)
698
+ return
699
+
700
+ all_headers = headers + nested_params
701
+
702
+ # --- Row 1: descriptions ---
703
+ ws.cell(row=1, column=1, value="navigate")
704
+ n_fixed = len(headers)
705
+ for i, pname in enumerate(nested_params):
706
+ col_idx = n_fixed + 1 + i
707
+ if pname in spec.descriptions:
708
+ ws.cell(row=1, column=col_idx, value=spec.descriptions[pname])
709
+
710
+ # --- Row 2: headers ---
711
+ for col_idx, hdr in enumerate(all_headers, start=1):
712
+ ws.cell(row=2, column=col_idx, value=hdr)
713
+
714
+ # --- Collect data rows ---
715
+ data_rows: list[list[Any]] = []
716
+
717
+ for entity_class in spec.entity_classes:
718
+ entities = db_contents.entities.get(entity_class, [])
719
+
720
+ for entity in entities:
721
+ entity_byname = entity["entity_byname"]
722
+ alts = _find_alternatives_for_entity(
723
+ entity_class, entity_byname, spec, db_contents
724
+ )
725
+
726
+ for alt in alts:
727
+ # Collect nested Map data for this entity+alt
728
+ nested_data: dict[str, Map] = {}
729
+ all_outer_indexes: set[str] = set()
730
+
731
+ for pname in nested_params:
732
+ key = (entity_class, entity_byname, pname, alt)
733
+ value = db_contents.parameter_values.get(key)
734
+ if value is not None and _is_map(value):
735
+ if any(_is_map(mv) for mv in value.values):
736
+ nested_data[pname] = value
737
+ for oi in value.indexes:
738
+ outer_str = str(_to_native(oi))
739
+ all_outer_indexes.add(outer_str)
740
+
741
+ if not nested_data:
742
+ continue
743
+
744
+ # Collect all (outer, inner) combos across all params
745
+ row_combos: set[tuple[str, str]] = set()
746
+ for pname, outer_map in nested_data.items():
747
+ for oi, inner_val in zip(outer_map.indexes, outer_map.values):
748
+ outer_str = str(_to_native(oi))
749
+ if _is_map(inner_val):
750
+ for ii in inner_val.indexes:
751
+ inner_str = str(_to_native(ii))
752
+ row_combos.add((outer_str, inner_str))
753
+
754
+ for outer_idx, inner_idx in sorted(row_combos):
755
+ row: list[Any] = [alt]
756
+
757
+ # Entity elements
758
+ for elem in entity_byname:
759
+ row.append(str(_to_native(elem)))
760
+
761
+ # current_solve_period and periods_included
762
+ row.append(outer_idx)
763
+ row.append(inner_idx)
764
+
765
+ # Parameter values
766
+ for pname in nested_params:
767
+ outer_map = nested_data.get(pname)
768
+ if outer_map is not None:
769
+ # Find the inner map at this outer index
770
+ found = False
771
+ for oi, inner_val in zip(outer_map.indexes, outer_map.values):
772
+ if str(_to_native(oi)) == outer_idx and _is_map(inner_val):
773
+ # Find value at inner index
774
+ for ii, iv in zip(inner_val.indexes, inner_val.values):
775
+ if str(_to_native(ii)) == inner_idx:
776
+ row.append(_to_native(iv))
777
+ found = True
778
+ break
779
+ break
780
+ if not found:
781
+ row.append(None)
782
+ else:
783
+ row.append(None)
784
+
785
+ data_rows.append(row)
786
+
787
+ # --- Sort rows ---
788
+ data_rows.sort(key=lambda r: tuple(str(v) if v is not None else "" for v in r))
789
+
790
+ # --- Write data rows ---
791
+ for row_idx, row_data in enumerate(data_rows, start=3):
792
+ for col_idx, value in enumerate(row_data, start=1):
793
+ if value is not None:
794
+ ws.cell(row=row_idx, column=col_idx, value=value)
795
+
796
+ # --- Formatting ---
797
+ n_entity_cols = len(spec.entity_columns)
798
+ n_extra = 2 # current_solve_period + periods_included
799
+
800
+ format_periodic_sheet(ws, n_entity_cols, n_extra)
801
+ add_navigate_link(ws)
802
+ auto_column_width(ws)
803
+
804
+
805
+ def write_nested_periodic_sheet_v2(
806
+ ws: Worksheet,
807
+ spec: SheetSpec,
808
+ db_contents: DatabaseContents,
809
+ ) -> None:
810
+ """Write a nested-periodic-layout sheet in v2 self-describing format.
811
+
812
+ Layout (solve_period_period):
813
+ Row 1: navigate | | | | description | data type | ...
814
+ Row 2: | | | | data type | string/float (2d-map) | ...
815
+ Row 3: alternative | entity: solve | index: current_solve_period | index: periods_included | parameter | param1 | ...
816
+ Row 4+: data...
817
+ """
818
+ # Only include params that actually have nested Map values
819
+ nested_params: list[str] = []
820
+ for pname in spec.parameter_names:
821
+ for (cls, _byname, p, _alt), value in db_contents.parameter_values.items():
822
+ if cls in spec.entity_classes and p == pname and _is_map(value):
823
+ if any(_is_map(mv) for mv in value.values):
824
+ nested_params.append(pname)
825
+ break
826
+
827
+ if not nested_params:
828
+ nested_params = list(spec.parameter_names)
829
+
830
+ # Build left-side columns
831
+ left_cols: list[str] = ["alternative"]
832
+ entity_label = _build_entity_def_label(spec)
833
+ left_cols.append(entity_label)
834
+ left_cols.append("index: current_solve_period")
835
+ left_cols.append("index: periods_included")
836
+
837
+ def_col = len(left_cols) + 1 # 1-based
838
+
839
+ # Right-side columns
840
+ right_cols: list[str] = ["parameter"]
841
+ right_cols.extend(nested_params)
842
+
843
+ # --- Row 1: descriptions ---
844
+ ws.cell(row=1, column=def_col, value="description")
845
+ ws.cell(row=1, column=def_col).fill = FILL_DESC_ROW
846
+ ws.cell(row=1, column=def_col).font = FONT_DESC_ROW
847
+ for i, pname in enumerate(nested_params):
848
+ col = def_col + 1 + i
849
+ desc = _get_param_description(pname, spec.entity_classes, db_contents)
850
+ if desc:
851
+ ws.cell(row=1, column=col, value=desc)
852
+ ws.cell(row=1, column=col).fill = FILL_DESC_DATA
853
+ ws.cell(row=1, column=col).font = FONT_DESC_DATA
854
+
855
+ # --- Row 2: data types ---
856
+ ws.cell(row=2, column=def_col, value="data type")
857
+ ws.cell(row=2, column=def_col).fill = FILL_DESC_ROW
858
+ ws.cell(row=2, column=def_col).font = FONT_DESC_ROW
859
+ for i, pname in enumerate(nested_params):
860
+ col = def_col + 1 + i
861
+ dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="nested_periodic")
862
+ ws.cell(row=2, column=col, value=dtype)
863
+ ws.cell(row=2, column=col).fill = FILL_DESC_DATA
864
+ ws.cell(row=2, column=col).font = FONT_DESC_DATA
865
+
866
+ # --- Optional row 3: defaults (only when at least one param has one) ---
867
+ param_defaults = {
868
+ pname: _get_param_default_string(pname, spec.entity_classes, db_contents)
869
+ for pname in nested_params
870
+ }
871
+ has_default_row = any(v is not None for v in param_defaults.values())
872
+ if has_default_row:
873
+ default_row = 3
874
+ ws.cell(row=default_row, column=def_col, value="default")
875
+ ws.cell(row=default_row, column=def_col).fill = FILL_DESC_ROW
876
+ ws.cell(row=default_row, column=def_col).font = FONT_DESC_ROW
877
+ for i, pname in enumerate(nested_params):
878
+ col = def_col + 1 + i
879
+ dv = param_defaults[pname]
880
+ if dv is not None:
881
+ ws.cell(row=default_row, column=col, value=dv)
882
+ ws.cell(row=default_row, column=col).fill = FILL_DESC_DATA
883
+ ws.cell(row=default_row, column=col).font = FONT_DESC_DATA
884
+ def_row = 4
885
+ else:
886
+ def_row = 3
887
+ data_start_row = def_row + 1
888
+
889
+ # --- Definition row ---
890
+ for col_idx, label in enumerate(left_cols, start=1):
891
+ ws.cell(row=def_row, column=col_idx, value=label)
892
+ for col_idx, label in enumerate(right_cols, start=def_col):
893
+ ws.cell(row=def_row, column=col_idx, value=label)
894
+
895
+ # --- Collect data rows ---
896
+ data_rows: list[list[Any]] = []
897
+
898
+ for entity_class in spec.entity_classes:
899
+ entities = db_contents.entities.get(entity_class, [])
900
+
901
+ for entity in entities:
902
+ entity_byname = entity["entity_byname"]
903
+ alts = _find_alternatives_for_entity(
904
+ entity_class, entity_byname, spec, db_contents
905
+ )
906
+
907
+ for alt in alts:
908
+ nested_data: dict[str, Map] = {}
909
+ for pname in nested_params:
910
+ key = (entity_class, entity_byname, pname, alt)
911
+ value = db_contents.parameter_values.get(key)
912
+ if value is not None and _is_map(value):
913
+ if any(_is_map(mv) for mv in value.values):
914
+ nested_data[pname] = value
915
+
916
+ if not nested_data:
917
+ continue
918
+
919
+ row_combos: set[tuple[str, str]] = set()
920
+ for pname, outer_map in nested_data.items():
921
+ for oi, inner_val in zip(outer_map.indexes, outer_map.values):
922
+ outer_str = str(_to_native(oi))
923
+ if _is_map(inner_val):
924
+ for ii in inner_val.indexes:
925
+ inner_str = str(_to_native(ii))
926
+ row_combos.add((outer_str, inner_str))
927
+
928
+ for outer_idx, inner_idx in sorted(row_combos):
929
+ row: list[Any] = [alt]
930
+ for elem in entity_byname:
931
+ row.append(str(_to_native(elem)))
932
+ row.append(outer_idx)
933
+ row.append(inner_idx)
934
+ row.append(None) # def column
935
+
936
+ for pname in nested_params:
937
+ outer_map = nested_data.get(pname)
938
+ if outer_map is not None:
939
+ found = False
940
+ for oi, inner_val in zip(outer_map.indexes, outer_map.values):
941
+ if str(_to_native(oi)) == outer_idx and _is_map(inner_val):
942
+ for ii, iv in zip(inner_val.indexes, inner_val.values):
943
+ if str(_to_native(ii)) == inner_idx:
944
+ row.append(_to_native(iv))
945
+ found = True
946
+ break
947
+ break
948
+ if not found:
949
+ row.append(None)
950
+ else:
951
+ row.append(None)
952
+
953
+ data_rows.append(row)
954
+
955
+ data_rows.sort(key=lambda r: tuple(str(v) if v is not None else "" for v in r))
956
+
957
+ # --- Write data rows ---
958
+ for row_idx, row_data in enumerate(data_rows, start=data_start_row):
959
+ for col_idx, value in enumerate(row_data, start=1):
960
+ if value is not None:
961
+ ws.cell(row=row_idx, column=col_idx, value=value)
962
+
963
+ # --- Reference section ---
964
+ last_data_col = def_col + len(right_cols) - 1
965
+ _write_param_reference(
966
+ ws, last_data_col + 1, spec, db_contents, header_row=def_row,
967
+ n_data_rows=len(data_rows), layout="nested_periodic",
968
+ shown_params=nested_params,
969
+ )
970
+
971
+ # --- Formatting ---
972
+ n_entity_cols = len(spec.entity_columns)
973
+ index_col_positions = {3, 4} # current_solve_period and periods_included
974
+ last_data_col_nested = def_col + len(right_cols) - 1
975
+ format_constant_sheet_v2(
976
+ ws, n_entity_cols, 2, def_col, index_col_positions,
977
+ last_data_col_nested, def_row=def_row,
978
+ )
979
+ add_navigate_link(ws)
980
+ auto_column_width(ws, min_param_width=_min_param_width,
981
+ non_param_width=_non_param_width,
982
+ def_col_width=_def_col_width,
983
+ index_col_width=_index_col_width,
984
+ header_row=def_row, def_col=def_col,
985
+ index_cols=index_col_positions)
986
+
987
+
988
+ # ---------------------------------------------------------------------------
989
+ # Timeseries sheet
990
+ # ---------------------------------------------------------------------------
991
+
992
+
993
+ def write_timeseries_sheet(
994
+ ws: Worksheet,
995
+ spec: SheetSpec,
996
+ db_contents: DatabaseContents,
997
+ ) -> None:
998
+ """Write a transposed timeseries-layout sheet."""
999
+ # Collect all data columns: each is (entity_class, entity_byname, param, alt, Map)
1000
+ columns: list[tuple[str, tuple, str, str, Map]] = []
1001
+ all_time_indexes: set[str] = set()
1002
+
1003
+ for entity_class in spec.entity_classes:
1004
+ entities = db_contents.entities.get(entity_class, [])
1005
+
1006
+ for entity in entities:
1007
+ entity_byname = entity["entity_byname"]
1008
+
1009
+ for pname in spec.parameter_names:
1010
+ for alt in db_contents.alternatives:
1011
+ key = (entity_class, entity_byname, pname, alt)
1012
+ value = db_contents.parameter_values.get(key)
1013
+ if value is not None and _is_map(value):
1014
+ # Only include time-indexed Maps on timeseries sheets
1015
+ if not _is_time_indexed_map(value):
1016
+ continue
1017
+ columns.append(
1018
+ (entity_class, entity_byname, pname, alt, value)
1019
+ )
1020
+ for idx in value.indexes:
1021
+ all_time_indexes.add(str(_to_native(idx)))
1022
+
1023
+ if not columns:
1024
+ # Write minimal headers even if no data
1025
+ ws.cell(row=1, column=1, value="navigate")
1026
+ ws.cell(row=1, column=2, value="alternative")
1027
+ ws.cell(row=2, column=2, value="parameter")
1028
+ add_navigate_link(ws)
1029
+ auto_column_width(ws)
1030
+ return
1031
+
1032
+ # Sort columns by (alt, entity_byname, param)
1033
+ columns.sort(key=lambda c: (c[3], c[1], c[2]))
1034
+
1035
+ # Sort time indexes
1036
+ sorted_times = sorted(all_time_indexes)
1037
+
1038
+ # Determine header structure
1039
+ n_entity_dims = len(spec.entity_columns)
1040
+ has_direction = spec.direction_column is not None
1041
+
1042
+ # n_header_rows = 2 (alt, param) + n_entity_dims + (1 if direction)
1043
+ n_header_rows = 2 + n_entity_dims
1044
+ if has_direction:
1045
+ n_header_rows += 1
1046
+
1047
+ # --- Write header rows ---
1048
+ # Row 1: 'navigate' | 'alternative' | alt values...
1049
+ ws.cell(row=1, column=1, value="navigate")
1050
+ ws.cell(row=1, column=2, value="alternative")
1051
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
1052
+ ws.cell(row=1, column=col_idx, value=alt)
1053
+
1054
+ # Row 2: '' | 'parameter' | param names...
1055
+ ws.cell(row=2, column=2, value="parameter")
1056
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
1057
+ ws.cell(row=2, column=col_idx, value=pname)
1058
+
1059
+ # Entity dimension rows
1060
+ for dim_idx in range(n_entity_dims):
1061
+ row = 3 + dim_idx
1062
+ dim_label = spec.entity_columns[dim_idx]
1063
+ ws.cell(row=row, column=2, value=dim_label)
1064
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
1065
+ if dim_idx < len(byname):
1066
+ ws.cell(row=row, column=col_idx, value=str(_to_native(byname[dim_idx])))
1067
+
1068
+ # Direction row (if applicable)
1069
+ if has_direction:
1070
+ dir_row = 2 + n_entity_dims + 1
1071
+ ws.cell(row=dir_row, column=2, value=spec.direction_column)
1072
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
1073
+ direction = _get_direction(ec, spec)
1074
+ if direction:
1075
+ ws.cell(row=dir_row, column=col_idx, value=direction)
1076
+
1077
+ # 'time' label on the last header row, column A
1078
+ ws.cell(row=n_header_rows, column=1, value="time")
1079
+
1080
+ # --- Write time data rows ---
1081
+ # Build index lookup for each column's Map for fast access
1082
+ col_index_maps: list[dict[str, Any]] = []
1083
+ for ec, byname, pname, alt, m in columns:
1084
+ idx_map: dict[str, Any] = {}
1085
+ for mi, mv in zip(m.indexes, m.values):
1086
+ idx_map[str(_to_native(mi))] = _to_native(mv)
1087
+ col_index_maps.append(idx_map)
1088
+
1089
+ for time_row_idx, time_val in enumerate(sorted_times):
1090
+ row = n_header_rows + 1 + time_row_idx
1091
+ ws.cell(row=row, column=1, value=time_val)
1092
+ for col_idx, idx_map in enumerate(col_index_maps):
1093
+ value = idx_map.get(time_val)
1094
+ if value is not None:
1095
+ ws.cell(row=row, column=col_idx + 3, value=value)
1096
+
1097
+ # --- Formatting ---
1098
+ format_timeseries_sheet(ws, n_header_rows)
1099
+ add_navigate_link(ws)
1100
+ auto_column_width(ws)
1101
+
1102
+
1103
+ # ---------------------------------------------------------------------------
1104
+ # Link sheet
1105
+ # ---------------------------------------------------------------------------
1106
+
1107
+
1108
+ def write_link_sheet(
1109
+ ws: Worksheet,
1110
+ spec: SheetSpec,
1111
+ db_contents: DatabaseContents,
1112
+ ) -> None:
1113
+ """Write a link-only (relationship) sheet."""
1114
+ # Row 1: headers = entity dimension names
1115
+ for col_idx, col_name in enumerate(spec.entity_columns, start=1):
1116
+ ws.cell(row=1, column=col_idx, value=col_name)
1117
+
1118
+ # Collect and sort entity rows
1119
+ entity_class = spec.entity_classes[0] if spec.entity_classes else None
1120
+ if not entity_class:
1121
+ format_link_sheet(ws)
1122
+ auto_column_width(ws)
1123
+ return
1124
+
1125
+ entities = db_contents.entities.get(entity_class, [])
1126
+ rows: list[tuple] = []
1127
+ for entity in entities:
1128
+ byname = entity["entity_byname"]
1129
+ rows.append(tuple(str(_to_native(v)) for v in byname))
1130
+
1131
+ rows.sort()
1132
+
1133
+ for row_idx, row_data in enumerate(rows, start=2):
1134
+ for col_idx, value in enumerate(row_data, start=1):
1135
+ ws.cell(row=row_idx, column=col_idx, value=value)
1136
+
1137
+ format_link_sheet(ws)
1138
+ auto_column_width(ws)
1139
+
1140
+
1141
+ # ---------------------------------------------------------------------------
1142
+ # Scenario sheet
1143
+ # ---------------------------------------------------------------------------
1144
+
1145
+
1146
+ def write_scenario_sheet(
1147
+ ws: Worksheet,
1148
+ db_contents: DatabaseContents,
1149
+ include_stochastics: bool = False,
1150
+ ) -> None:
1151
+ """Write the scenario sheet with formatting.
1152
+
1153
+ Layout:
1154
+ Row 1: navigate | Scenario names
1155
+ Row 2: index: | scenario_1 | scenario_2 | ...
1156
+ Row 3: base_alternative | alt | alt | ...
1157
+ Row 4-33: alternative_1..30 | ...
1158
+ """
1159
+ scenarios = db_contents.scenarios
1160
+ font_dark = Font(color=Color(theme=1, tint=0.0))
1161
+
1162
+ # Row 1: 'navigate' | 'Scenario names'
1163
+ cell_b1 = ws.cell(row=1, column=2, value="Scenario names")
1164
+ cell_b1.font = font_dark
1165
+
1166
+ # Row 2: 'index:' | scenario names
1167
+ ws.cell(row=2, column=1, value="index:")
1168
+ for col_idx, sc in enumerate(scenarios, start=2):
1169
+ cell = ws.cell(row=2, column=col_idx, value=sc["name"])
1170
+ cell.fill = FILL_ENTITY_HEADER
1171
+ cell.font = font_dark
1172
+
1173
+ # Row 3: 'base_alternative' | alt names (rank 0)
1174
+ cell_a3 = ws.cell(row=3, column=1, value="base_alternative")
1175
+ cell_a3.fill = FILL_PARAM_HEADER
1176
+ cell_a3.font = font_dark
1177
+ for col_idx, sc in enumerate(scenarios, start=2):
1178
+ alts = sc.get("alternatives", [])
1179
+ if alts:
1180
+ cell = ws.cell(row=3, column=col_idx, value=alts[0][0])
1181
+ cell.font = font_dark
1182
+
1183
+ # Rows 4-33: alternative_1..30 — autofill to 30
1184
+ max_alts = max((len(sc.get("alternatives", [])) for sc in scenarios), default=0)
1185
+ total_alt_rows = max(30, max_alts)
1186
+
1187
+ for alt_idx in range(1, total_alt_rows + 1):
1188
+ row = 3 + alt_idx
1189
+ cell_label = ws.cell(row=row, column=1, value=f"alternative_{alt_idx}")
1190
+ cell_label.fill = FILL_PARAM_HEADER
1191
+ cell_label.font = font_dark
1192
+ for col_idx, sc in enumerate(scenarios, start=2):
1193
+ alts = sc.get("alternatives", [])
1194
+ if alt_idx < len(alts):
1195
+ cell = ws.cell(row=row, column=col_idx, value=alts[alt_idx][0])
1196
+ cell.font = font_dark
1197
+
1198
+ add_navigate_link(ws)
1199
+ auto_column_width(ws)
1200
+
1201
+
1202
+ # ---------------------------------------------------------------------------
1203
+ # Version sheet
1204
+ # ---------------------------------------------------------------------------
1205
+
1206
+
1207
+ def write_version_sheet(
1208
+ ws: Worksheet,
1209
+ version: float | None,
1210
+ ) -> None:
1211
+ """Write the version sheet."""
1212
+ if version is not None:
1213
+ version_int = int(version) if version == int(version) else version
1214
+ ws.cell(row=1, column=1, value=f"Generated from FlexTool sqlite version: {version_int}")
1215
+ else:
1216
+ ws.cell(row=1, column=1, value="Generated from FlexTool sqlite")
1217
+
1218
+
1219
+ # ---------------------------------------------------------------------------
1220
+ # Array-transposed sheet (model_solve_sequence, model_periods_available)
1221
+ # ---------------------------------------------------------------------------
1222
+
1223
+
1224
+ def write_array_transposed_sheet_v2(
1225
+ ws: Worksheet,
1226
+ spec: SheetSpec,
1227
+ db_contents: DatabaseContents,
1228
+ ) -> None:
1229
+ """Write an array-parameter sheet in transposed layout.
1230
+
1231
+ Like profile_t but for array parameters: columns are (entity, alt)
1232
+ combinations, rows are array index positions.
1233
+
1234
+ Layout:
1235
+ Row 1: navigate | parameter: solves | data type: boolean (array) | description: ...
1236
+ Row 2: INFO: ... (optional, from _info_rows setting)
1237
+ Row N: | entity: model | flexTool | flexTool | ...
1238
+ Row N+1: | alternative | init | 5weeks | ...
1239
+ Row N+2: index: | 0 | solve_a | solve_b | ...
1240
+ ...autofill to 30
1241
+ """
1242
+ if not spec.parameter_names:
1243
+ add_navigate_link(ws)
1244
+ return
1245
+
1246
+ pname = spec.parameter_names[0]
1247
+ entity_label = _build_entity_def_label(spec)
1248
+ dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="constant")
1249
+ desc = _get_param_description(pname, spec.entity_classes, db_contents)
1250
+
1251
+ # Collect data: (entity_byname, alt) → array values
1252
+ columns: list[tuple[str, tuple, list[Any]]] = [] # (alt, entity_byname, values)
1253
+ max_array_len = 0
1254
+
1255
+ for entity_class in spec.entity_classes:
1256
+ for entity in db_contents.entities.get(entity_class, []):
1257
+ entity_byname = entity["entity_byname"]
1258
+ for alt in db_contents.alternatives:
1259
+ key = (entity_class, entity_byname, pname, alt)
1260
+ value = db_contents.parameter_values.get(key)
1261
+ if value is not None and _is_array(value):
1262
+ arr = [_to_native(v) for v in value.values]
1263
+ columns.append((alt, entity_byname, arr))
1264
+ if len(arr) > max_array_len:
1265
+ max_array_len = len(arr)
1266
+ elif value is not None and _is_scalar(value):
1267
+ # String value = single-element array
1268
+ columns.append((alt, entity_byname, [_to_native(value)]))
1269
+ if 1 > max_array_len:
1270
+ max_array_len = 1
1271
+
1272
+ columns.sort(key=lambda c: (c[0], c[1]))
1273
+
1274
+ cur_row = 1
1275
+
1276
+ # INFO row (starts from definition column B, not A)
1277
+ info_text = _info_rows.get(spec.sheet_name)
1278
+ if not info_text:
1279
+ info_text = "INFO: Add new data columns as needed by right clicking on the column name and then selecting 'Insert columns before'."
1280
+ if info_text:
1281
+ ws.cell(row=cur_row, column=2, value=info_text)
1282
+ cur_row += 1
1283
+
1284
+ # Triplet row (parameter info)
1285
+ triplet = f"parameter: {pname} | data type: {dtype}"
1286
+ if desc:
1287
+ triplet += f" | description: {desc}"
1288
+ default_str = _get_param_default_string(pname, spec.entity_classes, db_contents)
1289
+ if default_str is not None:
1290
+ triplet += f" | default: {default_str}"
1291
+ ws.cell(row=cur_row, column=2, value=triplet)
1292
+ cur_row += 1
1293
+
1294
+ # Entity row
1295
+ ws.cell(row=cur_row, column=2, value=entity_label)
1296
+ for col_idx, (alt, byname, _arr) in enumerate(columns, start=3):
1297
+ ws.cell(row=cur_row, column=col_idx, value=str(_to_native(byname[0])))
1298
+ cur_row += 1
1299
+
1300
+ # Alternative row — this is the last header row; put index: label in A
1301
+ ws.cell(row=cur_row, column=1, value="index:")
1302
+ ws.cell(row=cur_row, column=2, value="alternative")
1303
+ for col_idx, (alt, byname, _arr) in enumerate(columns, start=3):
1304
+ ws.cell(row=cur_row, column=col_idx, value=alt)
1305
+ cur_row += 1
1306
+
1307
+ # Data rows — index numbers in column A, values in columns C+
1308
+ n_index_rows = max(30, max_array_len)
1309
+ index_start_row = cur_row
1310
+
1311
+ for idx in range(n_index_rows):
1312
+ row = index_start_row + idx
1313
+ ws.cell(row=row, column=1, value=idx)
1314
+ for col_idx, (alt, byname, arr) in enumerate(columns, start=3):
1315
+ if idx < len(arr):
1316
+ ws.cell(row=row, column=col_idx, value=arr[idx])
1317
+
1318
+ # Formatting — build row_types based on actual row positions
1319
+ n_header_rows = index_start_row - 1
1320
+ row_types: dict[int, str] = {}
1321
+ r = 1
1322
+ if info_text:
1323
+ # INFO row has no special formatting type (plain text)
1324
+ r += 1
1325
+ row_types[r] = "param_info" # triplet row
1326
+ r += 1
1327
+ row_types[r] = "entity"
1328
+ r += 1
1329
+ row_types[r] = "alternative"
1330
+
1331
+ arr_last_data_col = max(len(columns) + 2, 3)
1332
+ format_timeseries_sheet_v2(ws, n_header_rows, single_param=True,
1333
+ row_types=row_types, last_data_col=arr_last_data_col)
1334
+
1335
+ # Column A: green index fill (header + data + 100 extra rows)
1336
+ from openpyxl.formatting.rule import CellIsRule
1337
+ extend_end = index_start_row + n_index_rows + 100
1338
+ ws.cell(row=n_header_rows, column=1).fill = FILL_INDEX_HEADER
1339
+ ws.conditional_formatting.add(
1340
+ f"A{index_start_row}:A{extend_end}",
1341
+ CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'],
1342
+ fill=FILL_INDEX_DATA),
1343
+ )
1344
+ # Column B: grey definition column fill for data rows
1345
+ ws.conditional_formatting.add(
1346
+ f"B{index_start_row}:B{extend_end}",
1347
+ CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'],
1348
+ fill=FILL_DEF_COL),
1349
+ )
1350
+
1351
+ add_navigate_link(ws) # always A1
1352
+ auto_column_width(ws)
1353
+
1354
+
1355
+ # ---------------------------------------------------------------------------
1356
+ # Stochastic sheet (_s)
1357
+ # ---------------------------------------------------------------------------
1358
+
1359
+
1360
+ def _flatten_nested_map(
1361
+ value: Any,
1362
+ depth: int,
1363
+ prefix: tuple = (),
1364
+ ) -> list[tuple[tuple[str, ...], Any]]:
1365
+ """Flatten a nested Map into (index_tuple, scalar_value) pairs.
1366
+
1367
+ For a 3d Map (Map of Map of Map), returns tuples of length 3.
1368
+ For a 4d Map, length 4, etc.
1369
+ """
1370
+ if depth <= 1 or not _is_map(value):
1371
+ if _is_map(value):
1372
+ # Deepest level — expand to individual entries
1373
+ return [(prefix + (str(_to_native(idx)),), _to_native(val))
1374
+ for idx, val in zip(value.indexes, value.values)]
1375
+ else:
1376
+ return [(prefix, _to_native(value))]
1377
+
1378
+ result: list[tuple[tuple[str, ...], Any]] = []
1379
+ for idx, inner in zip(value.indexes, value.values):
1380
+ idx_str = str(_to_native(idx))
1381
+ result.extend(_flatten_nested_map(inner, depth - 1, prefix + (idx_str,)))
1382
+ return result
1383
+
1384
+
1385
+ def _get_nested_index_names(value: Any, depth: int) -> list[str]:
1386
+ """Extract index names from each level of a nested Map.
1387
+
1388
+ Falls back to stochastic_index_names defaults from settings when
1389
+ the DB stores 'x' or empty index names.
1390
+ """
1391
+ names: list[str] = []
1392
+ current = value
1393
+ for level in range(depth):
1394
+ if _is_map(current) and hasattr(current, "index_name"):
1395
+ iname = current.index_name
1396
+ if iname and iname != "x":
1397
+ names.append(iname)
1398
+ elif level < len(_stochastic_index_names):
1399
+ names.append(_stochastic_index_names[level])
1400
+ else:
1401
+ names.append(f"index_{level + 1}")
1402
+ if current.values:
1403
+ current = current.values[0]
1404
+ else:
1405
+ break
1406
+ else:
1407
+ break
1408
+ return names
1409
+
1410
+
1411
+ def write_stochastic_sheet_v2(
1412
+ ws: Worksheet,
1413
+ spec: SheetSpec,
1414
+ db_contents: DatabaseContents,
1415
+ ) -> None:
1416
+ """Write a stochastic (_s) sheet in the v2 self-describing format.
1417
+
1418
+ Layout (e.g. profile_s_profile with 3d map: branch × analysis_time × time):
1419
+ Row 1: navigate | | | parameter: profile | data type: ... | ...
1420
+ Row 2: | | | entity: profile | wind1 | wind1
1421
+ Row 3: index: branch | index: analysis_time | index: time | alternative | 1week_rolling | 2day
1422
+ Row 4+: realized | t0001 | t0001 | | 0.5 | 0.3
1423
+ """
1424
+ if not spec.parameter_names:
1425
+ add_navigate_link(ws)
1426
+ return
1427
+
1428
+ pname = spec.parameter_names[0]
1429
+ entity_label = _build_entity_def_label(spec)
1430
+ dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="timeseries")
1431
+ desc = _get_param_description(pname, spec.entity_classes, db_contents)
1432
+
1433
+ # Collect data: flatten nested maps and build columns
1434
+ columns: list[tuple[str, tuple, str, list[tuple[tuple[str, ...], Any]]]] = []
1435
+ # (alt, entity_byname, entity_class, flat_data)
1436
+ max_depth = 0
1437
+ index_names: list[str] = []
1438
+
1439
+ for entity_class in spec.entity_classes:
1440
+ for entity in db_contents.entities.get(entity_class, []):
1441
+ entity_byname = entity["entity_byname"]
1442
+ for alt in db_contents.alternatives:
1443
+ key = (entity_class, entity_byname, pname, alt)
1444
+ value = db_contents.parameter_values.get(key)
1445
+ if value is None or not _is_map(value):
1446
+ continue
1447
+ # Determine nesting depth
1448
+ depth = 1
1449
+ current = value
1450
+ while _is_map(current) and current.values and _is_map(current.values[0]):
1451
+ depth += 1
1452
+ current = current.values[0]
1453
+ if depth < 2:
1454
+ continue # not stochastic (< 3d)
1455
+
1456
+ if not index_names:
1457
+ index_names = _get_nested_index_names(value, depth)
1458
+ max_depth = max(max_depth, depth)
1459
+
1460
+ flat = _flatten_nested_map(value, depth)
1461
+ columns.append((alt, entity_byname, entity_class, flat))
1462
+
1463
+ columns.sort(key=lambda c: (c[0], c[1]))
1464
+
1465
+ # Determine expected depth from parameter type_list even if no data
1466
+ if not max_depth:
1467
+ for entity_class in spec.entity_classes:
1468
+ for pdef in db_contents.parameter_definitions.get(entity_class, []):
1469
+ if pdef["name"] == pname:
1470
+ tl = pdef.get("parameter_type_list") or ()
1471
+ if "4d_map" in tl:
1472
+ max_depth = 4
1473
+ elif "3d_map" in tl:
1474
+ max_depth = 3
1475
+ break
1476
+ if not max_depth:
1477
+ max_depth = 3 # default for stochastic
1478
+
1479
+ n_index_cols = max_depth
1480
+ def_col = n_index_cols + 1 # 1-based
1481
+
1482
+ # Pad index_names to match depth using stochastic defaults
1483
+ while len(index_names) < n_index_cols:
1484
+ level = len(index_names)
1485
+ if level < len(_stochastic_index_names):
1486
+ index_names.append(_stochastic_index_names[level])
1487
+ else:
1488
+ index_names.append(f"index_{level + 1}")
1489
+
1490
+ # Collect unique index tuples preserving original Map order
1491
+ seen_tuples: set[tuple[str, ...]] = set()
1492
+ ordered_tuples: list[tuple[str, ...]] = []
1493
+ for _alt, _bn, _ec, flat in columns:
1494
+ for idx_tuple, _val in flat:
1495
+ if idx_tuple not in seen_tuples:
1496
+ seen_tuples.add(idx_tuple)
1497
+ ordered_tuples.append(idx_tuple)
1498
+
1499
+ # Build column data lookups (index_tuple → value)
1500
+ col_lookups: list[dict[tuple[str, ...], Any]] = []
1501
+ for _alt, _bn, _ec, flat in columns:
1502
+ lookup = {idx_tuple: val for idx_tuple, val in flat}
1503
+ col_lookups.append(lookup)
1504
+
1505
+ # ── Write header rows ────────────────────────────────────────
1506
+ cur_row = 1
1507
+
1508
+ # Row 1: navigate + triplet in def_col
1509
+ triplet = f"parameter: {pname} | data type: {dtype}"
1510
+ if desc:
1511
+ triplet += f" | description: {desc}"
1512
+ default_str = _get_param_default_string(pname, spec.entity_classes, db_contents)
1513
+ if default_str is not None:
1514
+ triplet += f" | default: {default_str}"
1515
+ ws.cell(row=cur_row, column=def_col, value=triplet)
1516
+ row_types: dict[int, str] = {cur_row: "param_info"}
1517
+ cur_row += 1
1518
+
1519
+ # Row 2: entity
1520
+ ws.cell(row=cur_row, column=def_col, value=entity_label)
1521
+ for col_idx, (alt, byname, ec, _flat) in enumerate(columns, start=def_col + 1):
1522
+ if byname:
1523
+ ws.cell(row=cur_row, column=col_idx, value=str(_to_native(byname[0])))
1524
+ row_types[cur_row] = "entity"
1525
+ cur_row += 1
1526
+
1527
+ # Row 3: index labels + alternative
1528
+ for i in range(n_index_cols):
1529
+ iname = index_names[i] if i < len(index_names) and index_names[i] else f"index_{i+1}"
1530
+ ws.cell(row=cur_row, column=i + 1, value=f"index: {iname}")
1531
+ ws.cell(row=cur_row, column=def_col, value="alternative")
1532
+ for col_idx, (alt, byname, ec, _flat) in enumerate(columns, start=def_col + 1):
1533
+ ws.cell(row=cur_row, column=col_idx, value=alt)
1534
+ row_types[cur_row] = "alternative"
1535
+ n_header_rows = cur_row
1536
+ cur_row += 1
1537
+
1538
+ # ── Write data rows ──────────────────────────────────────────
1539
+ for idx_tuple in ordered_tuples:
1540
+ for i, idx_val in enumerate(idx_tuple):
1541
+ ws.cell(row=cur_row, column=i + 1, value=idx_val)
1542
+ for col_idx, lookup in enumerate(col_lookups, start=def_col + 1):
1543
+ val = lookup.get(idx_tuple)
1544
+ if val is not None:
1545
+ ws.cell(row=cur_row, column=col_idx, value=val)
1546
+ cur_row += 1
1547
+
1548
+ # ── Convenience section (two-column gap) ────────────────────
1549
+ last_data_col = def_col + len(columns)
1550
+ color_end_col = last_data_col + 2 # extend coloring 2 cols beyond data
1551
+ _write_param_reference(
1552
+ ws, color_end_col, spec, db_contents, header_row=n_header_rows,
1553
+ n_data_rows=len(ordered_tuples), layout="stochastic",
1554
+ shown_params=list(spec.parameter_names),
1555
+ )
1556
+
1557
+ # ── Formatting (extend to color_end_col) ─────────────────────
1558
+ format_timeseries_sheet_v2(ws, n_header_rows, single_param=True,
1559
+ row_types=row_types, last_data_col=color_end_col)
1560
+
1561
+ # Index columns: green fill
1562
+ from openpyxl.formatting.rule import CellIsRule
1563
+ from openpyxl.utils import get_column_letter
1564
+ data_end = cur_row + 100
1565
+ for i in range(n_index_cols):
1566
+ cl = get_column_letter(i + 1)
1567
+ ws.cell(row=n_header_rows, column=i + 1).fill = FILL_INDEX_HEADER
1568
+ ws.conditional_formatting.add(
1569
+ f"{cl}{n_header_rows + 1}:{cl}{data_end}",
1570
+ CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'],
1571
+ fill=FILL_INDEX_DATA),
1572
+ )
1573
+
1574
+ # Definition column: dark grey
1575
+ def_cl = get_column_letter(def_col)
1576
+ ws.conditional_formatting.add(
1577
+ f"{def_cl}{n_header_rows + 1}:{def_cl}{data_end}",
1578
+ CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'],
1579
+ fill=FILL_DEF_COL),
1580
+ )
1581
+
1582
+ add_navigate_link(ws)
1583
+ auto_column_width(ws, min_param_width=_min_param_width,
1584
+ non_param_width=_non_param_width,
1585
+ def_col_width=_def_col_width,
1586
+ index_col_width=_index_col_width,
1587
+ header_row=n_header_rows, def_col=def_col,
1588
+ last_data_col=last_data_col)
1589
+
1590
+
1591
+ # ---------------------------------------------------------------------------
1592
+ # v2 Ladder sheet (facet-leaf params — commodity.price_ladder_*)
1593
+ # ---------------------------------------------------------------------------
1594
+
1595
+
1596
+ def _xlsx_safe_float(value: Any) -> Any:
1597
+ """Convert ``±inf`` / ``nan`` to string sentinels for openpyxl.
1598
+
1599
+ openpyxl silently writes a blank cell for IEEE non-finite floats, which
1600
+ breaks round-trip for ``price_ladder_*`` whose top-tier ``quantity``
1601
+ is canonically ``inf``. The reader recognises the string sentinels
1602
+ and converts them back to ``float`` via :func:`_convert_value`.
1603
+ """
1604
+ if isinstance(value, float):
1605
+ if value != value: # NaN
1606
+ return "nan"
1607
+ if value == float("inf"):
1608
+ return "inf"
1609
+ if value == float("-inf"):
1610
+ return "-inf"
1611
+ return value
1612
+ if isinstance(value, (int, str)):
1613
+ return value
1614
+ nat = _to_native(value)
1615
+ if isinstance(nat, float):
1616
+ return _xlsx_safe_float(nat)
1617
+ return nat
1618
+
1619
+
1620
+ def _ladder_axis_name(axis: Any) -> str:
1621
+ """Return the human-readable axis name for an :class:`AxisName` value.
1622
+
1623
+ Maps registry axis tokens to the index labels we emit in the sheet.
1624
+ """
1625
+ # Use the axis's value (contract token) to derive a label. We keep
1626
+ # the mapping local to the writer so the registry stays UI-free.
1627
+ token = getattr(axis, "value", str(axis))
1628
+ return {"d": "period", "t": "time", "i": "tier"}.get(token, token)
1629
+
1630
+
1631
+ def _walk_ladder_map(
1632
+ value: Any,
1633
+ depth: int,
1634
+ facet_keys_set: "frozenset[str]",
1635
+ ) -> "list[tuple[tuple[str, ...], dict[str, Any]]]":
1636
+ """Walk a depth-``depth`` Map down to the facet leaf.
1637
+
1638
+ Returns a list of ``(outer_index_tuple, {facet_key: value, ...})``.
1639
+ The leaf is itself a depth-1 Map with facet keys ``price`` / ``quantity``.
1640
+ ``depth`` here counts the outer non-facet Map levels — depth=1 means
1641
+ ``Map(tier -> Map(price/quantity))``, depth=2 means
1642
+ ``Map(period -> Map(tier -> Map(price/quantity)))``.
1643
+ """
1644
+ results: list[tuple[tuple[str, ...], dict[str, Any]]] = []
1645
+ if not _is_map(value):
1646
+ return results
1647
+ if depth == 1:
1648
+ # value itself is Map(tier -> facet_map); walk one level
1649
+ for idx, leaf in zip(value.indexes, value.values):
1650
+ if not _is_map(leaf):
1651
+ continue
1652
+ facets: dict[str, Any] = {}
1653
+ for fk, fv in zip(leaf.indexes, leaf.values):
1654
+ fk_s = str(_to_native(fk))
1655
+ if fk_s in facet_keys_set:
1656
+ facets[fk_s] = _to_native(fv)
1657
+ if facets:
1658
+ results.append((
1659
+ (str(_to_native(idx)),),
1660
+ facets,
1661
+ ))
1662
+ return results
1663
+ # depth >= 2: peel one level and recurse
1664
+ for idx, sub in zip(value.indexes, value.values):
1665
+ for inner_tuple, facets in _walk_ladder_map(sub, depth - 1, facet_keys_set):
1666
+ results.append((
1667
+ (str(_to_native(idx)), *inner_tuple),
1668
+ facets,
1669
+ ))
1670
+ return results
1671
+
1672
+
1673
+ def write_ladder_sheet_v2(
1674
+ ws: Worksheet,
1675
+ spec: SheetSpec,
1676
+ db_contents: DatabaseContents,
1677
+ ) -> None:
1678
+ """Write a ladder-layout sheet for facet-leaf params.
1679
+
1680
+ Layout (e.g. ``price_ladder_cumulative``, depth-2):
1681
+ Row 1: navigate | | | | | description | description
1682
+ Row 2: | data type: float | data type: float
1683
+ Row 3: alternative | entity: commodity | index: tier | parameter: price | parameter: quantity
1684
+ Row 4+: base | coal | 1 | 20.0 | 1.0
1685
+
1686
+ For ``price_ladder_annual`` with mixed depth-2/depth-3 data, the
1687
+ period column is added when ANY value has the depth-3 shape; depth-2
1688
+ rows simply leave the period cell empty.
1689
+ """
1690
+ # Lazy registry import — kept off the hot path.
1691
+ from flextool.engine_polars._param_shapes import (
1692
+ LeafKind,
1693
+ PARAM_ALLOWED_SHAPES,
1694
+ facet_keys,
1695
+ shape_to_axes,
1696
+ )
1697
+
1698
+ if not spec.parameter_names:
1699
+ add_navigate_link(ws)
1700
+ return
1701
+
1702
+ pname = spec.parameter_names[0]
1703
+ entity_class = spec.entity_classes[0]
1704
+ allowed = PARAM_ALLOWED_SHAPES.get((entity_class, pname), set())
1705
+ facet_leaf_shapes = [
1706
+ s for s in allowed
1707
+ if shape_to_axes(s).leaf is LeafKind.FACET_PRICE_QUANTITY
1708
+ ]
1709
+ if not facet_leaf_shapes:
1710
+ # Registry didn't tag this param as facet-leaf — bail safely.
1711
+ add_navigate_link(ws)
1712
+ return
1713
+
1714
+ # Determine whether the period axis MAY be present (any allowed shape
1715
+ # carries AxisName.D in its outer levels).
1716
+ has_period_variant = any(
1717
+ any(getattr(ax, "value", "") == "d" for ax in shape_to_axes(s).map_levels)
1718
+ for s in facet_leaf_shapes
1719
+ )
1720
+ # The shared facet keys (we trust the registry to keep them identical
1721
+ # across both variants for a given param).
1722
+ leaf_kind = shape_to_axes(facet_leaf_shapes[0]).leaf
1723
+ facet_key_set = facet_keys(leaf_kind)
1724
+ # Deterministic facet ordering: price → quantity for the canonical
1725
+ # FACET_PRICE_QUANTITY leaf.
1726
+ facet_columns = ["price", "quantity"] if facet_key_set == frozenset(
1727
+ ("price", "quantity")
1728
+ ) else sorted(facet_key_set)
1729
+
1730
+ entity_label = _build_entity_def_label(spec)
1731
+
1732
+ # ── Collect data ────────────────────────────────────────────────
1733
+ # rows: list of (alt, entity_byname, period_or_None, tier, {facet: value})
1734
+ rows: list[tuple[str, tuple, str | None, str, dict[str, Any]]] = []
1735
+ any_period_in_data = False
1736
+
1737
+ for ent_cls in spec.entity_classes:
1738
+ for entity in db_contents.entities.get(ent_cls, []):
1739
+ entity_byname = entity["entity_byname"]
1740
+ for alt in db_contents.alternatives:
1741
+ key = (ent_cls, entity_byname, pname, alt)
1742
+ val = db_contents.parameter_values.get(key)
1743
+ if val is None or not _is_map(val):
1744
+ continue
1745
+ # Detect depth by inspecting nesting. Outer Map levels
1746
+ # exclude the final facet level: depth 1 == tier only,
1747
+ # depth 2 == period + tier.
1748
+ depth = 1
1749
+ cursor = val
1750
+ while _is_map(cursor) and cursor.values and _is_map(cursor.values[0]):
1751
+ nested = cursor.values[0]
1752
+ # If the nested Map is the facet leaf (indexes are facet keys),
1753
+ # stop counting.
1754
+ nested_keys = {str(_to_native(k)) for k in nested.indexes}
1755
+ if nested_keys and nested_keys.issubset(facet_key_set):
1756
+ break
1757
+ depth += 1
1758
+ cursor = nested
1759
+
1760
+ # Walk to leaf, emit rows.
1761
+ walked = _walk_ladder_map(val, depth, facet_key_set)
1762
+ for idx_tuple, facets in walked:
1763
+ if depth == 1:
1764
+ rows.append((alt, entity_byname, None, idx_tuple[0], facets))
1765
+ elif depth == 2:
1766
+ any_period_in_data = True
1767
+ rows.append((
1768
+ alt, entity_byname,
1769
+ idx_tuple[0], idx_tuple[1], facets,
1770
+ ))
1771
+ else:
1772
+ # Outer levels beyond 2 not currently registered;
1773
+ # skip rather than invent semantics.
1774
+ continue
1775
+
1776
+ # Sort for stable output.
1777
+ rows.sort(key=lambda r: (r[0], tuple(str(x) for x in r[1]),
1778
+ r[2] or "", r[3]))
1779
+
1780
+ # ── Build header columns ───────────────────────────────────────
1781
+ n_entity_cols = len(spec.entity_columns)
1782
+ n_dims = n_entity_cols
1783
+
1784
+ left_cols: list[str] = ["alternative", entity_label]
1785
+ if n_dims > 1:
1786
+ for dim_name in spec.entity_columns:
1787
+ left_cols.append(dim_name)
1788
+
1789
+ show_period_col = has_period_variant and any_period_in_data
1790
+ period_col_pos: int | None = None
1791
+ if show_period_col:
1792
+ left_cols.append("index: period")
1793
+ period_col_pos = len(left_cols) # 1-based
1794
+
1795
+ left_cols.append("index: tier")
1796
+ tier_col_pos = len(left_cols) # 1-based
1797
+
1798
+ def_col = len(left_cols) + 1 # 1-based
1799
+
1800
+ # ── Write metadata rows (description, data type) ────────────────
1801
+ # Layout follows the constant-sheet convention so the existing v2
1802
+ # reader recognises the right-of-crossing param columns:
1803
+ # Row 1, col 1 "navigate"
1804
+ # Row 1, def_col "description" (row label)
1805
+ # Row 1, def_col+1.. DB-level description per facet column
1806
+ # Row 2, def_col "data type" (row label)
1807
+ # Row 2, def_col+1.. "float" per facet column
1808
+ desc = _get_param_description(pname, spec.entity_classes, db_contents)
1809
+ ws.cell(row=1, column=1, value="navigate")
1810
+ ws.cell(row=1, column=def_col, value="description")
1811
+ for i, _facet in enumerate(facet_columns):
1812
+ if desc:
1813
+ ws.cell(row=1, column=def_col + 1 + i, value=desc)
1814
+ ws.cell(row=2, column=def_col, value="data type")
1815
+ for i, _facet in enumerate(facet_columns):
1816
+ ws.cell(row=2, column=def_col + 1 + i, value="float")
1817
+
1818
+ def_row = 3
1819
+ data_start_row = def_row + 1
1820
+
1821
+ # ── Definition row ─────────────────────────────────────────────
1822
+ for col_idx, label in enumerate(left_cols, start=1):
1823
+ ws.cell(row=def_row, column=col_idx, value=label)
1824
+ # def_col holds "parameter" (matches constant-sheet convention so
1825
+ # the standard v2 reader recognises right-of-crossing param cols).
1826
+ ws.cell(row=def_row, column=def_col, value="parameter")
1827
+ for i, facet in enumerate(facet_columns):
1828
+ ws.cell(row=def_row, column=def_col + 1 + i, value=facet)
1829
+
1830
+ # ── Column maps ────────────────────────────────────────────────
1831
+ col_alt = 1
1832
+ if n_dims <= 1:
1833
+ entity_data_cols = [2]
1834
+ else:
1835
+ entity_data_cols = list(range(3, 3 + n_dims))
1836
+
1837
+ facet_col_map: dict[str, int] = {
1838
+ f: def_col + 1 + i for i, f in enumerate(facet_columns)
1839
+ }
1840
+
1841
+ # ── Write data rows ────────────────────────────────────────────
1842
+ for r_offset, (alt, entity_byname, period, tier, facets) in enumerate(rows):
1843
+ r = data_start_row + r_offset
1844
+ ws.cell(row=r, column=col_alt, value=alt)
1845
+ for i, col in enumerate(entity_data_cols):
1846
+ if i < len(entity_byname):
1847
+ ws.cell(row=r, column=col,
1848
+ value=str(_to_native(entity_byname[i])))
1849
+ if period_col_pos is not None and period is not None:
1850
+ ws.cell(row=r, column=period_col_pos, value=period)
1851
+ ws.cell(row=r, column=tier_col_pos, value=tier)
1852
+ for facet, fval in facets.items():
1853
+ col = facet_col_map.get(facet)
1854
+ if col is None:
1855
+ continue
1856
+ ws.cell(row=r, column=col, value=_xlsx_safe_float(fval))
1857
+
1858
+ # ── Reference section + formatting ─────────────────────────────
1859
+ last_data_col = def_col + len(facet_columns)
1860
+ index_col_positions: set[int] = {tier_col_pos}
1861
+ if period_col_pos is not None:
1862
+ index_col_positions.add(period_col_pos)
1863
+ n_extra_cols = 1 + (1 if show_period_col else 0)
1864
+ format_constant_sheet_v2(
1865
+ ws, n_entity_cols, n_extra_cols, def_col, index_col_positions,
1866
+ last_data_col, def_row=def_row,
1867
+ )
1868
+ add_navigate_link(ws)
1869
+ auto_column_width(
1870
+ ws,
1871
+ min_param_width=_min_param_width,
1872
+ non_param_width=_non_param_width,
1873
+ def_col_width=_def_col_width,
1874
+ index_col_width=_index_col_width,
1875
+ header_row=def_row, def_col=def_col,
1876
+ index_cols=index_col_positions,
1877
+ last_data_col=last_data_col,
1878
+ )
1879
+
1880
+
1881
+ # ---------------------------------------------------------------------------
1882
+ # Navigate sheet
1883
+ # ---------------------------------------------------------------------------
1884
+
1885
+
1886
+ def write_navigate_sheet(
1887
+ ws: Worksheet,
1888
+ all_specs: list[SheetSpec],
1889
+ navigate_groups: list[dict[str, Any]] | None = None,
1890
+ version: float | None = None,
1891
+ ) -> None:
1892
+ """Write the navigate sheet with grouped, colour-coded hyperlinks.
1893
+
1894
+ Args:
1895
+ ws: Target worksheet.
1896
+ all_specs: All sheet specifications (used for fallback if no groups).
1897
+ navigate_groups: Navigate group configuration from export_settings.yaml.
1898
+ version: FlexTool DB version to display.
1899
+ """
1900
+ # Build set of sheet names that actually exist in the workbook specs
1901
+ existing_sheets: set[str] = {spec.sheet_name for spec in all_specs if spec.layout != "navigate"}
1902
+
1903
+ if not navigate_groups:
1904
+ # Fallback: flat 3-column list (original behaviour)
1905
+ _write_navigate_flat(ws, all_specs, existing_sheets)
1906
+ return
1907
+
1908
+ # --- Row 1: column headers ---
1909
+ ws.cell(row=1, column=1, value="Constants")
1910
+ ws.cell(row=1, column=2, value="Periodic")
1911
+ ws.cell(row=1, column=3, value="Timeseries")
1912
+ ws.cell(row=1, column=4, value="Stochastic")
1913
+
1914
+ # --- Column F: help text ---
1915
+ help_lines = [
1916
+ "FlexTool can take parameter data in four ways:",
1917
+ "- constants (sheet ends with '_c') - Almost any value can be a constant or can be set as a time series.",
1918
+ "- period series (sheets ends with '_p') - These parameters have a period index (e.g. year).",
1919
+ "- time series (sheets ends with '_t') - These parameters can have values for each timestep.",
1920
+ "- stochastic (sheets ends with '_s') - These parameters have forecast branches with time series.",
1921
+ "",
1922
+ "Some sheets establish only relationships between entities without additional parameters.",
1923
+ "",
1924
+ "Parameters with valid types (drop-down lists) show allowed values when you select the cell.",
1925
+ ]
1926
+ for i, text in enumerate(help_lines):
1927
+ if text:
1928
+ ws.cell(row=1 + i, column=6, value=text)
1929
+
1930
+ # --- Version info at E15 ---
1931
+ if version is not None:
1932
+ version_int = int(version) if version == int(version) else version
1933
+ ws.cell(row=15, column=6, value="FlexTool DB version:")
1934
+ ws.cell(row=15, column=7, value=version_int)
1935
+
1936
+ # --- Write grouped rows ---
1937
+ current_row = 2 # start after header row
1938
+
1939
+ for group in navigate_groups:
1940
+ group_color = group["color"]
1941
+ group_fill = PatternFill(patternType="solid", fgColor=group_color)
1942
+ rows_in_group = group.get("rows", [])
1943
+
1944
+ for sheet_row in rows_in_group:
1945
+ # Place each sheet name into columns A, B, C in order
1946
+ for col_offset, sheet_name in enumerate(sheet_row):
1947
+ if sheet_name in existing_sheets:
1948
+ col = col_offset + 1 # 1-based
1949
+ cell = ws.cell(row=current_row, column=col, value=sheet_name)
1950
+ cell.hyperlink = f"#{sheet_name}!A1"
1951
+ cell.font = FONT_NAVIGATE_LINK
1952
+
1953
+ # Apply group fill to columns A, B, C for this row
1954
+ for col in range(1, 5):
1955
+ ws.cell(row=current_row, column=col).fill = group_fill
1956
+
1957
+ current_row += 1
1958
+
1959
+ # Blank separator row after each group
1960
+ current_row += 1
1961
+
1962
+ # --- Sheet mappings section (documents multi-mapping entity classes) ---
1963
+ mapping_specs = [s for s in all_specs
1964
+ if s.direction_map and len(s.entity_classes) > 1
1965
+ and s.sheet_name in existing_sheets]
1966
+ if mapping_specs:
1967
+ current_row += 1
1968
+ header_fill = PatternFill(patternType="solid", fgColor="BDD7EE")
1969
+ ws.cell(row=current_row, column=1, value="Sheet mappings")
1970
+ ws.cell(row=current_row, column=1).fill = header_fill
1971
+ ws.cell(row=current_row, column=2, value="Entity classes")
1972
+ ws.cell(row=current_row, column=2).fill = header_fill
1973
+ ws.cell(row=current_row, column=3, value="Filter column")
1974
+ ws.cell(row=current_row, column=3).fill = header_fill
1975
+ ws.cell(row=current_row, column=4, value="Filter value")
1976
+ ws.cell(row=current_row, column=4).fill = header_fill
1977
+ ws.cell(row=current_row, column=5, value="Dimensions")
1978
+ ws.cell(row=current_row, column=5).fill = header_fill
1979
+ ws.cell(row=current_row, column=6, value="Orientation")
1980
+ ws.cell(row=current_row, column=6).fill = header_fill
1981
+ current_row += 1
1982
+
1983
+ seen_sheets: set[str] = set()
1984
+ for spec in mapping_specs:
1985
+ if spec.sheet_name in seen_sheets:
1986
+ continue
1987
+ seen_sheets.add(spec.sheet_name)
1988
+
1989
+ orientation = "rows" if spec.layout == "timeseries" else "columns"
1990
+ for cls_name, direction_val in spec.direction_map.items():
1991
+ ws.cell(row=current_row, column=1, value=spec.sheet_name)
1992
+ ws.cell(row=current_row, column=2, value=cls_name)
1993
+ ws.cell(row=current_row, column=3, value=spec.direction_column)
1994
+ ws.cell(row=current_row, column=4, value=direction_val)
1995
+ dims = ", ".join(spec.entity_columns)
1996
+ ws.cell(row=current_row, column=5, value=dims)
1997
+ ws.cell(row=current_row, column=6, value=orientation)
1998
+ current_row += 1
1999
+
2000
+ auto_column_width(ws)
2001
+
2002
+
2003
+ def _write_navigate_flat(
2004
+ ws: Worksheet,
2005
+ all_specs: list[SheetSpec],
2006
+ existing_sheets: set[str],
2007
+ ) -> None:
2008
+ """Fallback: write a flat 3-column navigate sheet (original behaviour)."""
2009
+ constants: list[str] = []
2010
+ periodic: list[str] = []
2011
+ timeseries: list[str] = []
2012
+
2013
+ for spec in all_specs:
2014
+ if spec.layout == "navigate":
2015
+ continue
2016
+ elif spec.layout in ("periodic", "nested_periodic"):
2017
+ periodic.append(spec.sheet_name)
2018
+ elif spec.layout == "timeseries":
2019
+ timeseries.append(spec.sheet_name)
2020
+ else:
2021
+ constants.append(spec.sheet_name)
2022
+
2023
+ ws.cell(row=1, column=1, value="Constants")
2024
+ ws.cell(row=1, column=2, value="Periodic")
2025
+ ws.cell(row=1, column=3, value="Timeseries")
2026
+
2027
+ for row_idx, name in enumerate(constants, start=2):
2028
+ cell = ws.cell(row=row_idx, column=1, value=name)
2029
+ cell.hyperlink = f"#{name}!A1"
2030
+ cell.font = FONT_NAVIGATE_LINK
2031
+
2032
+ for row_idx, name in enumerate(periodic, start=2):
2033
+ cell = ws.cell(row=row_idx, column=2, value=name)
2034
+ cell.hyperlink = f"#{name}!A1"
2035
+ cell.font = FONT_NAVIGATE_LINK
2036
+
2037
+ for row_idx, name in enumerate(timeseries, start=2):
2038
+ cell = ws.cell(row=row_idx, column=3, value=name)
2039
+ cell.hyperlink = f"#{name}!A1"
2040
+ cell.font = FONT_NAVIGATE_LINK
2041
+
2042
+ auto_column_width(ws)
2043
+
2044
+
2045
+ # ===========================================================================
2046
+ # v2 format writers — self-describing sheets with embedded metadata
2047
+ # ===========================================================================
2048
+
2049
+
2050
+ # Data type overrides from settings, set by export_to_excel before writing.
2051
+ # Maps (layout, param_name) -> override string.
2052
+ _data_type_overrides: dict[str, dict[str, str]] = {}
2053
+ _min_param_width: float = 10
2054
+ _non_param_width: float = 22
2055
+ _def_col_width: float = 11
2056
+ _index_col_width: float = 12
2057
+
2058
+
2059
+ def _get_param_data_type(
2060
+ param_name: str,
2061
+ entity_classes: list[str],
2062
+ db_contents: DatabaseContents,
2063
+ layout: str = "constant",
2064
+ ) -> str:
2065
+ """Determine the data type label for a parameter on a specific sheet layout.
2066
+
2067
+ The label tells the user what to enter in each cell and helps the reader
2068
+ interpret values correctly.
2069
+
2070
+ Returns labels like:
2071
+ "float", "string", "boolean (array)",
2072
+ "float (1d-map)", "string (1d-map)", "string/float (1d-map)"
2073
+ """
2074
+ # Check overrides first (e.g. nested_periodic convention for 2d-maps)
2075
+ overrides = _data_type_overrides.get(layout, {})
2076
+ if param_name in overrides:
2077
+ return overrides[param_name]
2078
+
2079
+ for entity_class in entity_classes:
2080
+ for pdef in db_contents.parameter_definitions.get(entity_class, []):
2081
+ if pdef["name"] == param_name:
2082
+ type_list = pdef.get("parameter_type_list")
2083
+ if not type_list:
2084
+ return "float"
2085
+ types = set(type_list)
2086
+
2087
+ # Pure array parameters — entered as boolean values
2088
+ if "array" in types and not (types & {"float", "1d_map", "2d_map", "3d_map", "4d_map"}):
2089
+ return "boolean (array)"
2090
+
2091
+ # On constant sheets: report the scalar type
2092
+ if layout == "constant":
2093
+ if "str" in types:
2094
+ return "string"
2095
+ return "float"
2096
+
2097
+ # On periodic sheets: array params are written one row per
2098
+ # array element with the period name as the cell value.
2099
+ # Labelling the column ``string (array)`` lets the v2
2100
+ # importer collapse the per-row records back into a
2101
+ # SpineDB Array(values=[period_names]), preserving the
2102
+ # original ``"array"`` type end-to-end. Downstream code
2103
+ # (``_solve_config.params_to_dict`` / ``periods_to_tuples``)
2104
+ # requires Array — Map<period, period> would silently
2105
+ # change the engine's solve identity to a (key, value)
2106
+ # tuple and break ``model.solves`` resolution.
2107
+ if layout == "periodic" and "array" in types:
2108
+ return "string (array)"
2109
+
2110
+ # On periodic/timeseries sheets: report the map dimension
2111
+ # relevant to THIS layout, not the highest available.
2112
+ # Periodic/timeseries use 1d_map; nested_periodic uses 2d_map;
2113
+ # 3d_map/4d_map are for stochastic sheets.
2114
+ if layout == "nested_periodic":
2115
+ map_suffix = "2d-map" if "2d_map" in types else "1d-map"
2116
+ elif layout in ("periodic", "timeseries"):
2117
+ map_suffix = "1d-map" if "1d_map" in types else ""
2118
+ elif "4d_map" in types:
2119
+ map_suffix = "4d-map"
2120
+ elif "3d_map" in types:
2121
+ map_suffix = "3d-map"
2122
+ elif "2d_map" in types:
2123
+ map_suffix = "2d-map"
2124
+ elif "1d_map" in types:
2125
+ map_suffix = "1d-map"
2126
+ else:
2127
+ map_suffix = ""
2128
+
2129
+ # Determine inner value type
2130
+ has_str = "str" in types
2131
+ has_float = "float" in types
2132
+ if has_str and not has_float:
2133
+ base = "string"
2134
+ elif has_float and not has_str:
2135
+ base = "float"
2136
+ elif has_str and has_float:
2137
+ base = "string/float"
2138
+ else:
2139
+ # No str or float info (pure map) — allow either
2140
+ base = "string/float"
2141
+
2142
+ if map_suffix:
2143
+ return f"{base} ({map_suffix})"
2144
+ return base
2145
+
2146
+ return "float"
2147
+
2148
+
2149
+ def _get_param_value_list(
2150
+ param_name: str,
2151
+ entity_classes: list[str],
2152
+ db_contents: DatabaseContents,
2153
+ ) -> list[str] | None:
2154
+ """Get the allowed value list for a parameter, if any."""
2155
+ for entity_class in entity_classes:
2156
+ for pdef in db_contents.parameter_definitions.get(entity_class, []):
2157
+ if pdef["name"] == param_name:
2158
+ vl_name = pdef.get("parameter_value_list_name")
2159
+ if vl_name and vl_name in db_contents.list_values:
2160
+ return [str(v) for v in db_contents.list_values[vl_name]]
2161
+ return None
2162
+
2163
+
2164
+ def _get_param_description(
2165
+ param_name: str,
2166
+ entity_classes: list[str],
2167
+ db_contents: DatabaseContents,
2168
+ ) -> str | None:
2169
+ """Get the description for a parameter from DB definitions."""
2170
+ for entity_class in entity_classes:
2171
+ for pdef in db_contents.parameter_definitions.get(entity_class, []):
2172
+ if pdef["name"] == param_name:
2173
+ return pdef.get("description") or None
2174
+ return None
2175
+
2176
+
2177
+ def _get_param_default_string(
2178
+ param_name: str,
2179
+ entity_classes: list[str],
2180
+ db_contents: DatabaseContents,
2181
+ ) -> str | None:
2182
+ """Return a stringified default value for a parameter, or None.
2183
+
2184
+ Scalars stringify directly. Maps/Arrays are rendered in a JSON-like
2185
+ form so they fit in a single Excel cell.
2186
+ """
2187
+ for entity_class in entity_classes:
2188
+ for pdef in db_contents.parameter_definitions.get(entity_class, []):
2189
+ if pdef["name"] == param_name:
2190
+ dv = pdef.get("default_value")
2191
+ if dv is None:
2192
+ return None
2193
+ return _format_default_value(dv)
2194
+ return None
2195
+
2196
+
2197
+ def _format_default_value(value: Any) -> str:
2198
+ """Render a parameter default value to a single-cell string."""
2199
+ if _is_map(value):
2200
+ parts = [
2201
+ f"{_to_native(idx)}: {_format_default_value(val)}"
2202
+ for idx, val in zip(value.indexes, value.values)
2203
+ ]
2204
+ return "{" + ", ".join(parts) + "}"
2205
+ if _is_array(value):
2206
+ return "[" + ", ".join(_format_default_value(v) for v in value.values) + "]"
2207
+ return str(_to_native(value))
2208
+
2209
+
2210
+ def _build_entity_def_label(spec: SheetSpec) -> str:
2211
+ """Build the 'entity: ...' definition label for the definition row.
2212
+
2213
+ When the spec merges multiple entity classes (e.g. unit__inputNode and
2214
+ unit__outputNode), uses the multi-class syntax so that the reader can
2215
+ reconstruct the mapping:
2216
+ entity: (unit__inputNode: (unit, node), unit__outputNode: (unit, node))
2217
+
2218
+ Otherwise uses the simple form:
2219
+ entity: unit, node
2220
+ """
2221
+ dims = ', '.join(spec.entity_columns)
2222
+ if spec.direction_map and len(spec.entity_classes) > 1:
2223
+ parts = []
2224
+ for cls_name in spec.entity_classes:
2225
+ parts.append(f"{cls_name}: ({dims})")
2226
+ return f"entity: ({', '.join(parts)})"
2227
+ return f"entity: {dims}"
2228
+
2229
+
2230
+ def _build_filter_label(spec: SheetSpec) -> str | None:
2231
+ """Build the filter column label for the definition row.
2232
+
2233
+ When the spec merges multiple entity classes, returns the full filter
2234
+ syntax so the reader can map filter values to entity classes:
2235
+ filter: {unit__inputNode: ^input$, unit__outputNode: ^output$}
2236
+
2237
+ Otherwise returns None.
2238
+ """
2239
+ if not spec.direction_column or not spec.direction_map:
2240
+ return None
2241
+ pairs = ", ".join(
2242
+ f"{cls}: ^{val}$"
2243
+ for cls, val in spec.direction_map.items()
2244
+ )
2245
+ return f"filter: {{{pairs}}}"
2246
+
2247
+
2248
+ def _add_data_validation(
2249
+ ws: Worksheet,
2250
+ param_names: list[str],
2251
+ def_col: int,
2252
+ ea_offset: int,
2253
+ spec: SheetSpec,
2254
+ db_contents: DatabaseContents,
2255
+ max_data_rows: int = 500,
2256
+ data_start_row: int = 4,
2257
+ ) -> None:
2258
+ """Add dropdown validation lists for parameters that have value lists.
2259
+
2260
+ Applies validation from *data_start_row* down to a reasonable limit
2261
+ (not the entire column) to avoid bloating the file.
2262
+ """
2263
+ from openpyxl.worksheet.datavalidation import DataValidation
2264
+
2265
+ last_row = min(data_start_row + max_data_rows, data_start_row + 500)
2266
+
2267
+ from openpyxl.utils import get_column_letter
2268
+
2269
+ # Entity existence column gets TRUE/FALSE dropdown
2270
+ if ea_offset > 0:
2271
+ ee_col = def_col + 1
2272
+ dv_ee = DataValidation(
2273
+ type="list",
2274
+ formula1='",TRUE,FALSE"',
2275
+ allow_blank=True,
2276
+ showDropDown=False,
2277
+ )
2278
+ dv_ee.error = "Select TRUE or FALSE"
2279
+ dv_ee.errorTitle = "Entity existence"
2280
+ ee_letter = get_column_letter(ee_col)
2281
+ dv_ee.sqref = f"{ee_letter}{data_start_row}:{ee_letter}{last_row}"
2282
+ ws.add_data_validation(dv_ee)
2283
+
2284
+ for i, pname in enumerate(param_names):
2285
+ col = def_col + 1 + ea_offset + i
2286
+ values = _get_param_value_list(pname, spec.entity_classes, db_contents)
2287
+
2288
+ # Boolean (array) params get TRUE/FALSE dropdown
2289
+ dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="constant")
2290
+ if dtype == "boolean (array)" and not values:
2291
+ values = ["TRUE", "FALSE"]
2292
+
2293
+ if not values:
2294
+ continue
2295
+ # Add empty choice so user can clear a selection
2296
+ all_values = [""] + values
2297
+ formula = ",".join(all_values)
2298
+ dv = DataValidation(
2299
+ type="list",
2300
+ formula1=f'"{formula}"',
2301
+ allow_blank=True,
2302
+ showDropDown=False,
2303
+ )
2304
+ dv.error = f"Value must be one of: {', '.join(values)}"
2305
+ dv.errorTitle = f"Invalid {pname}"
2306
+ col_letter = get_column_letter(col)
2307
+ dv.sqref = f"{col_letter}{data_start_row}:{col_letter}{last_row}"
2308
+ ws.add_data_validation(dv)
2309
+
2310
+
2311
+ # Period-only params from settings, set by export_to_excel.
2312
+ _period_only_params: dict[str, list[str]] = {}
2313
+ _time_structure_classes: set[str] = set()
2314
+ _info_rows: dict[str, str] = {}
2315
+ _stochastic_index_names: list[str] = ["forecast", "branch_time", "time", "is_realized"]
2316
+
2317
+
2318
+ def _is_param_valid_for_layout(
2319
+ pdef: dict, layout: str, entity_class: str = "",
2320
+ ) -> bool:
2321
+ """Check if a parameter is valid for a given sheet layout."""
2322
+ type_list = pdef.get("parameter_type_list")
2323
+ if not type_list:
2324
+ return layout == "constant"
2325
+ types = set(type_list)
2326
+ pname = pdef["name"]
2327
+
2328
+ if layout == "constant":
2329
+ return bool(types & {"str", "float", "array", "2d_map"})
2330
+ elif layout == "periodic":
2331
+ return bool(types & {"1d_map", "2d_map", "array"})
2332
+ elif layout == "timeseries":
2333
+ if not (types & {"1d_map", "3d_map", "4d_map"}):
2334
+ return False
2335
+ # Filter out period-only params
2336
+ period_only = _period_only_params.get(entity_class, [])
2337
+ if pname in period_only:
2338
+ return False
2339
+ return True
2340
+ return True
2341
+
2342
+
2343
+ def _get_all_param_defs_for_class(
2344
+ entity_classes: list[str],
2345
+ db_contents: DatabaseContents,
2346
+ layout: str = "constant",
2347
+ ) -> list[dict]:
2348
+ """Get parameter definitions valid for the given layout, deduplicated by name."""
2349
+ seen: set[str] = set()
2350
+ result: list[dict] = []
2351
+ for entity_class in entity_classes:
2352
+ for pdef in db_contents.parameter_definitions.get(entity_class, []):
2353
+ if pdef["name"] not in seen and _is_param_valid_for_layout(pdef, layout, entity_class):
2354
+ seen.add(pdef["name"])
2355
+ result.append(pdef)
2356
+ result.sort(key=lambda p: p["name"])
2357
+ return result
2358
+
2359
+
2360
+ def _write_param_reference(
2361
+ ws: Worksheet,
2362
+ start_col: int,
2363
+ spec: SheetSpec,
2364
+ db_contents: DatabaseContents,
2365
+ header_row: int = 3,
2366
+ n_data_rows: int = 0,
2367
+ layout: str = "constant",
2368
+ shown_params: list[str] | None = None,
2369
+ row_types: dict[int, str] | None = None,
2370
+ ) -> None:
2371
+ """Write reference sections to the right of the data area.
2372
+
2373
+ For transposed sheets with row_types (multi-param timeseries):
2374
+ Writes a "quadruplet" section mirroring description/data_type/
2375
+ alternative(empty)/parameter rows, plus a convenience section below.
2376
+
2377
+ For standard sheets:
2378
+ Writes only the convenience section (parameter name | description).
2379
+ """
2380
+ if not shown_params:
2381
+ return
2382
+
2383
+ # Get the full definitions for the shown params (preserving order)
2384
+ all_pdefs_map: dict[str, dict] = {}
2385
+ for entity_class in spec.entity_classes:
2386
+ for pdef in db_contents.parameter_definitions.get(entity_class, []):
2387
+ all_pdefs_map[pdef["name"]] = pdef
2388
+
2389
+ # Get ALL valid params for this layout (for the quadruplet — extra params not shown)
2390
+ all_valid = _get_all_param_defs_for_class(spec.entity_classes, db_contents, layout=layout)
2391
+ shown_set = set(shown_params)
2392
+ [p for p in all_valid if p["name"] not in shown_set]
2393
+
2394
+ pdefs = [all_pdefs_map[p] for p in shown_params if p in all_pdefs_map]
2395
+ if not pdefs:
2396
+ return
2397
+
2398
+ ref_col = start_col + 1 # after empty separator column
2399
+
2400
+ # ------------------------------------------------------------------
2401
+ # Quadruplet section for transposed sheets (description, data_type,
2402
+ # alternative[empty], parameter) — shows ALL valid params so user
2403
+ # can copy columns when adding new entities.
2404
+ # ------------------------------------------------------------------
2405
+ if row_types and all_valid:
2406
+ for i, pdef in enumerate(all_valid):
2407
+ col = ref_col + i
2408
+ pname = pdef["name"]
2409
+ for row_num, rtype in row_types.items():
2410
+ if rtype == "description":
2411
+ desc = pdef.get("description", "")
2412
+ if desc:
2413
+ ws.cell(row=row_num, column=col, value=desc)
2414
+ elif rtype == "data_type":
2415
+ dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout=layout)
2416
+ ws.cell(row=row_num, column=col, value=dtype)
2417
+ elif rtype == "alternative":
2418
+ pass # leave empty — user fills this when copying
2419
+ elif rtype == "parameter":
2420
+ ws.cell(row=row_num, column=col, value=pname)
2421
+ # entity row: leave empty
2422
+
2423
+ # ------------------------------------------------------------------
2424
+ # Convenience section (parameter | data type | description)
2425
+ # ------------------------------------------------------------------
2426
+ conv_row = header_row + 2 # one empty row after header
2427
+
2428
+ # Header
2429
+ ws.cell(row=conv_row, column=ref_col, value="Parameter")
2430
+ ws.cell(row=conv_row, column=ref_col).fill = FILL_PARAM_HEADER
2431
+ ws.cell(row=conv_row, column=ref_col + 1, value="Data type")
2432
+ ws.cell(row=conv_row, column=ref_col + 1).fill = FILL_PARAM_HEADER
2433
+ ws.cell(row=conv_row, column=ref_col + 2, value="Description")
2434
+ ws.cell(row=conv_row, column=ref_col + 2).fill = FILL_PARAM_HEADER
2435
+
2436
+ max_name_len = len("Parameter")
2437
+ for i, pdef in enumerate(pdefs):
2438
+ row = conv_row + 1 + i
2439
+ name = pdef["name"]
2440
+ ws.cell(row=row, column=ref_col, value=name)
2441
+ if len(name) > max_name_len:
2442
+ max_name_len = len(name)
2443
+ dtype = _get_param_data_type(name, spec.entity_classes, db_contents, layout=layout)
2444
+ ws.cell(row=row, column=ref_col + 1, value=dtype)
2445
+ desc = pdef.get("description")
2446
+ if desc:
2447
+ ws.cell(row=row, column=ref_col + 2, value=desc)
2448
+
2449
+ # Size the parameter name column and data type column
2450
+ from openpyxl.utils import get_column_letter
2451
+ col_letter = get_column_letter(ref_col)
2452
+ ws.column_dimensions[col_letter].width = max_name_len + 2
2453
+ # Data type column: 50% wider than param name column
2454
+ dt_col_letter = get_column_letter(ref_col + 1)
2455
+ ws.column_dimensions[dt_col_letter].width = int((max_name_len + 2) * 1.5)
2456
+
2457
+
2458
+ def _lock_metadata_cells(
2459
+ ws: Worksheet,
2460
+ meta_rows: int,
2461
+ meta_cols: int,
2462
+ data_start_row: int,
2463
+ data_start_col: int,
2464
+ max_data_rows: int = 500,
2465
+ ) -> None:
2466
+ """Apply sheet protection, locking metadata cells and unlocking data cells.
2467
+
2468
+ Uses column-level unlocking for efficiency: sets entire data columns as
2469
+ unlocked rather than individual cells. For the metadata columns in the
2470
+ data region, only the metadata rows stay locked (the default).
2471
+
2472
+ Skips protection entirely when the data region exceeds *max_data_rows*
2473
+ to avoid bloating the file with per-cell styles.
2474
+
2475
+ Args:
2476
+ ws: Target worksheet.
2477
+ meta_rows: Number of metadata rows (rows 1..meta_rows are locked).
2478
+ meta_cols: Number of metadata columns (columns 1..meta_cols are locked in data rows).
2479
+ data_start_row: First row of the data region.
2480
+ data_start_col: First column of the data region.
2481
+ max_data_rows: Skip protection if data exceeds this many rows.
2482
+ """
2483
+ # TODO: Re-enable sheet protection once LibreOffice compatibility is verified.
2484
+ # The current openpyxl set_password("") creates a hash that can crash
2485
+ # LibreOffice on some systems. Disabled for now.
2486
+ return
2487
+
2488
+
2489
+ # ---------------------------------------------------------------------------
2490
+ # v2 Constant sheet
2491
+ # ---------------------------------------------------------------------------
2492
+
2493
+
2494
+ def write_constant_sheet_v2(
2495
+ ws: Worksheet,
2496
+ spec: SheetSpec,
2497
+ db_contents: DatabaseContents,
2498
+ ) -> None:
2499
+ """Write a constant-layout sheet in the v2 self-describing format.
2500
+
2501
+ Layout:
2502
+ Row 1: navigate | (empty) | description | desc/param descriptions...
2503
+ Row 2: (empty) | (empty) | data type | string/float...
2504
+ Row 3: alternative | entity: X | [filter:] [index:] parameter | [entity existence] | param names...
2505
+ Row 4+: data
2506
+ """
2507
+ # Determine the definition column position
2508
+ n_entity_cols = len(spec.entity_columns)
2509
+ n_extra_entity = len(spec.extra_entity_columns)
2510
+ n_dims = n_entity_cols
2511
+
2512
+ # Build left-side column labels for row 3
2513
+ left_cols: list[str] = ["alternative"]
2514
+ entity_label = _build_entity_def_label(spec)
2515
+ left_cols.append(entity_label)
2516
+
2517
+ # For multi-dim entities, add ALL individual dim name columns after the
2518
+ # entity label (the entity label column has no data in data rows)
2519
+ if n_dims > 1:
2520
+ for dim_name in spec.entity_columns:
2521
+ left_cols.append(dim_name)
2522
+
2523
+ # Extra entity columns (e.g. left_node, right_node for connection)
2524
+ for extra_col in spec.extra_entity_columns:
2525
+ left_cols.append(extra_col)
2526
+
2527
+ # Filter column for direction
2528
+ filter_label = _build_filter_label(spec)
2529
+ filter_col_pos: int | None = None
2530
+ if filter_label:
2531
+ left_cols.append(filter_label)
2532
+ filter_col_pos = len(left_cols) # 1-based
2533
+
2534
+ # Unpack index column
2535
+ index_col_positions: set[int] = set()
2536
+ if spec.unpack_index_column:
2537
+ left_cols.append(f"index: {spec.unpack_index_column}")
2538
+ index_col_positions.add(len(left_cols)) # 1-based
2539
+
2540
+ def_col = len(left_cols) + 1 # 1-based position of definition column
2541
+
2542
+ # Build right-side columns (from def_col onward)
2543
+ right_cols: list[str] = ["parameter"]
2544
+ if spec.has_entity_alternative:
2545
+ right_cols.append("entity existence")
2546
+ # All parameters (pre_ea_params + parameter_names)
2547
+ all_params = list(spec.pre_ea_params) + list(spec.parameter_names)
2548
+ right_cols.extend(all_params)
2549
+
2550
+ # --- Row 1: navigate + descriptions (from def_col onward) ---
2551
+ ws.cell(row=1, column=1, value="navigate")
2552
+ ws.cell(row=1, column=def_col, value="description")
2553
+ ea_offset = 1 if spec.has_entity_alternative else 0
2554
+ if spec.has_entity_alternative:
2555
+ ws.cell(row=1, column=def_col + 1, value="Entity existence")
2556
+ for i, pname in enumerate(all_params):
2557
+ col = def_col + 1 + ea_offset + i
2558
+ desc = spec.descriptions.get(pname)
2559
+ if not desc:
2560
+ desc = _get_param_description(pname, spec.entity_classes, db_contents)
2561
+ if desc:
2562
+ ws.cell(row=1, column=col, value=desc)
2563
+
2564
+ # --- Row 2: data types (from def_col onward) ---
2565
+ ws.cell(row=2, column=def_col, value="data type")
2566
+ if spec.has_entity_alternative:
2567
+ ws.cell(row=2, column=def_col + 1, value="string")
2568
+ for i, pname in enumerate(all_params):
2569
+ col = def_col + 1 + ea_offset + i
2570
+ dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="constant")
2571
+ ws.cell(row=2, column=col, value=dtype)
2572
+
2573
+ # --- Optional row 3: defaults (only when at least one param has one) ---
2574
+ param_defaults = {
2575
+ pname: _get_param_default_string(pname, spec.entity_classes, db_contents)
2576
+ for pname in all_params
2577
+ }
2578
+ has_default_row = any(v is not None for v in param_defaults.values())
2579
+ if has_default_row:
2580
+ default_row = 3
2581
+ ws.cell(row=default_row, column=def_col, value="default")
2582
+ for i, pname in enumerate(all_params):
2583
+ col = def_col + 1 + ea_offset + i
2584
+ dv = param_defaults[pname]
2585
+ if dv is not None:
2586
+ ws.cell(row=default_row, column=col, value=dv)
2587
+ def_row = 4
2588
+ else:
2589
+ def_row = 3
2590
+ data_start_row = def_row + 1
2591
+
2592
+ # --- Definition row ---
2593
+ for col_idx, label in enumerate(left_cols, start=1):
2594
+ ws.cell(row=def_row, column=col_idx, value=label)
2595
+ for col_idx, label in enumerate(right_cols, start=def_col):
2596
+ ws.cell(row=def_row, column=col_idx, value=label)
2597
+
2598
+ # --- Map column positions for data writing ---
2599
+ col_alt = 1
2600
+
2601
+ if n_dims <= 1:
2602
+ entity_data_cols = [2]
2603
+ next_col_after_entity = 3
2604
+ else:
2605
+ # Multi-dim: entity label in col 2 (no data in data rows),
2606
+ # dims in cols 3..2+n_dims
2607
+ entity_data_cols = list(range(3, 3 + n_dims))
2608
+ next_col_after_entity = 3 + n_dims
2609
+
2610
+ extra_entity_data_cols = list(
2611
+ range(next_col_after_entity, next_col_after_entity + n_extra_entity)
2612
+ )
2613
+ next_col = next_col_after_entity + n_extra_entity
2614
+
2615
+ filter_data_col: int | None = None
2616
+ if filter_label:
2617
+ filter_data_col = next_col
2618
+ next_col += 1
2619
+
2620
+ unpack_data_col: int | None = None
2621
+ if spec.unpack_index_column:
2622
+ unpack_data_col = next_col
2623
+ next_col += 1
2624
+
2625
+ # def_col should match
2626
+ assert next_col == def_col, f"Column mismatch: expected def_col={def_col}, got {next_col}"
2627
+
2628
+ ea_data_col: int | None = None
2629
+ param_data_start = def_col + 1
2630
+ if spec.has_entity_alternative:
2631
+ ea_data_col = def_col + 1
2632
+ param_data_start = def_col + 2
2633
+
2634
+ param_col_map: dict[str, int] = {}
2635
+ for i, pname in enumerate(all_params):
2636
+ param_col_map[pname] = param_data_start + i
2637
+
2638
+ # --- Collect data rows ---
2639
+ data_rows: list[list[tuple[int, Any]]] = []
2640
+
2641
+ if spec.unpack_index_column:
2642
+ _collect_unpack_rows_v2(
2643
+ data_rows, spec, db_contents,
2644
+ col_alt=col_alt,
2645
+ entity_data_cols=entity_data_cols,
2646
+ extra_entity_data_cols=extra_entity_data_cols,
2647
+ filter_data_col=filter_data_col,
2648
+ unpack_data_col=unpack_data_col,
2649
+ ea_data_col=ea_data_col,
2650
+ param_col_map=param_col_map,
2651
+ )
2652
+ else:
2653
+ _collect_constant_rows_v2(
2654
+ data_rows, spec, db_contents,
2655
+ col_alt=col_alt,
2656
+ entity_data_cols=entity_data_cols,
2657
+ extra_entity_data_cols=extra_entity_data_cols,
2658
+ filter_data_col=filter_data_col,
2659
+ ea_data_col=ea_data_col,
2660
+ param_col_map=param_col_map,
2661
+ )
2662
+
2663
+ # --- Sort rows ---
2664
+ data_rows.sort(key=lambda r: tuple(
2665
+ str(v) if v is not None else "" for _, v in r
2666
+ ))
2667
+
2668
+ # --- Write data rows (starting at data_start_row) ---
2669
+ for row_idx, row_cells in enumerate(data_rows, start=data_start_row):
2670
+ for col, value in row_cells:
2671
+ if value is not None:
2672
+ ws.cell(row=row_idx, column=col, value=value)
2673
+
2674
+ # --- Write parameter reference section ---
2675
+ last_data_col = def_col + len(right_cols) - 1
2676
+ _write_param_reference(
2677
+ ws, last_data_col + 1, spec, db_contents, header_row=def_row,
2678
+ n_data_rows=len(data_rows), layout="constant",
2679
+ shown_params=all_params,
2680
+ )
2681
+
2682
+ # --- Formatting ---
2683
+ n_extra = (
2684
+ n_extra_entity
2685
+ + (1 if filter_label else 0)
2686
+ + (1 if spec.unpack_index_column else 0)
2687
+ )
2688
+ format_constant_sheet_v2(
2689
+ ws, n_entity_cols, n_extra, def_col, index_col_positions,
2690
+ last_data_col, def_row=def_row,
2691
+ )
2692
+ add_navigate_link(ws)
2693
+
2694
+ # --- Data validation (dropdown lists) for parameters with value lists ---
2695
+ _add_data_validation(
2696
+ ws, all_params, def_col, ea_offset, spec, db_contents,
2697
+ max_data_rows=max(len(data_rows), 1) + 3,
2698
+ data_start_row=data_start_row,
2699
+ )
2700
+
2701
+ # --- Filter dropdown for merged classes ---
2702
+ if filter_col_pos is not None and spec.direction_map:
2703
+ from openpyxl.worksheet.datavalidation import DataValidation
2704
+ from openpyxl.utils import get_column_letter
2705
+ filter_values = sorted(set(spec.direction_map.values()))
2706
+ filter_list = ",".join([""] + filter_values)
2707
+ dv_filter = DataValidation(
2708
+ type="list",
2709
+ formula1=f'"{filter_list}"',
2710
+ allow_blank=True,
2711
+ showDropDown=False,
2712
+ )
2713
+ dv_filter.error = f"Select: {', '.join(filter_values)}"
2714
+ dv_filter.errorTitle = spec.direction_column or "Filter"
2715
+ cl = get_column_letter(filter_col_pos)
2716
+ last_row = max(len(data_rows) + data_start_row - 1, data_start_row + 500)
2717
+ dv_filter.sqref = f"{cl}{data_start_row}:{cl}{last_row}"
2718
+ ws.add_data_validation(dv_filter)
2719
+
2720
+ auto_column_width(ws, min_param_width=_min_param_width,
2721
+ non_param_width=_non_param_width,
2722
+ def_col_width=_def_col_width,
2723
+ index_col_width=_index_col_width,
2724
+ header_row=def_row, def_col=def_col,
2725
+ index_cols=index_col_positions,
2726
+ last_data_col=last_data_col)
2727
+
2728
+ # --- Lock metadata ---
2729
+ _lock_metadata_cells(
2730
+ ws,
2731
+ meta_rows=def_row,
2732
+ meta_cols=def_col,
2733
+ data_start_row=data_start_row,
2734
+ data_start_col=1,
2735
+ )
2736
+
2737
+
2738
+ def _collect_constant_rows_v2(
2739
+ data_rows: list[list[tuple[int, Any]]],
2740
+ spec: SheetSpec,
2741
+ db_contents: DatabaseContents,
2742
+ *,
2743
+ col_alt: int,
2744
+ entity_data_cols: list[int],
2745
+ extra_entity_data_cols: list[int],
2746
+ filter_data_col: int | None,
2747
+ ea_data_col: int | None,
2748
+ param_col_map: dict[str, int],
2749
+ ) -> None:
2750
+ """Collect rows for a v2 constant sheet (non-unpack)."""
2751
+ all_params = list(spec.pre_ea_params) + list(spec.parameter_names)
2752
+
2753
+ for entity_class in spec.entity_classes:
2754
+ entities = db_contents.entities.get(entity_class, [])
2755
+ direction = _get_direction(entity_class, spec)
2756
+
2757
+ for entity in entities:
2758
+ entity_byname = entity["entity_byname"]
2759
+ alts = _find_alternatives_for_entity(
2760
+ entity_class, entity_byname, spec, db_contents
2761
+ )
2762
+
2763
+ for alt in alts:
2764
+ cells: list[tuple[int, Any]] = []
2765
+
2766
+ # Alternative
2767
+ cells.append((col_alt, alt))
2768
+
2769
+ # Entity dimension values
2770
+ for i, col in enumerate(entity_data_cols):
2771
+ if i < len(entity_byname):
2772
+ cells.append((col, str(_to_native(entity_byname[i]))))
2773
+
2774
+ # Extra entity columns
2775
+ if spec.extra_entity_columns:
2776
+ extras = _get_extra_columns_for_entity(
2777
+ entity_byname, entity_class, spec, db_contents
2778
+ )
2779
+ for i, col in enumerate(extra_entity_data_cols):
2780
+ if i < len(extras):
2781
+ cells.append((col, extras[i]))
2782
+
2783
+ # Direction/filter value
2784
+ if filter_data_col is not None and direction is not None:
2785
+ cells.append((filter_data_col, direction))
2786
+
2787
+ # Entity existence
2788
+ if ea_data_col is not None:
2789
+ ea_key = (entity_class, entity_byname, alt)
2790
+ ea_val = db_contents.entity_alternatives.get(ea_key)
2791
+ if ea_val is not None:
2792
+ cells.append((ea_data_col, "TRUE" if ea_val else "FALSE"))
2793
+
2794
+ # Parameter values — check for Arrays
2795
+ param_values: dict[str, Any] = {}
2796
+ has_array = False
2797
+ array_param: str | None = None
2798
+ array_elements: list[Any] = []
2799
+
2800
+ for pname in all_params:
2801
+ key = (entity_class, entity_byname, pname, alt)
2802
+ value = db_contents.parameter_values.get(key)
2803
+
2804
+ if value is None:
2805
+ param_values[pname] = None
2806
+ elif _is_map(value):
2807
+ param_values[pname] = None
2808
+ elif _is_array(value):
2809
+ has_array = True
2810
+ array_param = pname
2811
+ array_elements = [_to_native(v) for v in value.values]
2812
+ param_values[pname] = None
2813
+ elif _is_scalar(value):
2814
+ param_values[pname] = _to_native(value)
2815
+ else:
2816
+ param_values[pname] = None
2817
+
2818
+ if has_array and array_elements and array_param is not None:
2819
+ for elem in array_elements:
2820
+ expanded_cells = list(cells)
2821
+ for pname in all_params:
2822
+ col = param_col_map[pname]
2823
+ if pname == array_param:
2824
+ expanded_cells.append((col, elem))
2825
+ elif param_values[pname] is not None:
2826
+ expanded_cells.append((col, param_values[pname]))
2827
+ data_rows.append(expanded_cells)
2828
+ else:
2829
+ for pname in all_params:
2830
+ if param_values[pname] is not None:
2831
+ cells.append((param_col_map[pname], param_values[pname]))
2832
+ data_rows.append(cells)
2833
+
2834
+
2835
+ def _collect_unpack_rows_v2(
2836
+ data_rows: list[list[tuple[int, Any]]],
2837
+ spec: SheetSpec,
2838
+ db_contents: DatabaseContents,
2839
+ *,
2840
+ col_alt: int,
2841
+ entity_data_cols: list[int],
2842
+ extra_entity_data_cols: list[int],
2843
+ filter_data_col: int | None,
2844
+ unpack_data_col: int | None,
2845
+ ea_data_col: int | None,
2846
+ param_col_map: dict[str, int],
2847
+ ) -> None:
2848
+ """Collect rows for a v2 unpacked-Map constant sheet."""
2849
+ for entity_class in spec.entity_classes:
2850
+ entities = db_contents.entities.get(entity_class, [])
2851
+ direction = _get_direction(entity_class, spec)
2852
+
2853
+ for entity in entities:
2854
+ entity_byname = entity["entity_byname"]
2855
+ alts = _find_alternatives_for_entity(
2856
+ entity_class, entity_byname, spec, db_contents
2857
+ )
2858
+
2859
+ for alt in alts:
2860
+ # Collect all map indexes across all unpack params
2861
+ all_indexes: set[str] = set()
2862
+ param_maps: dict[str, Map] = {}
2863
+
2864
+ for pname in spec.parameter_names:
2865
+ key = (entity_class, entity_byname, pname, alt)
2866
+ value = db_contents.parameter_values.get(key)
2867
+ if value is not None and _is_map(value):
2868
+ param_maps[pname] = value
2869
+ for idx in value.indexes:
2870
+ all_indexes.add(str(_to_native(idx)))
2871
+
2872
+ if not all_indexes:
2873
+ continue
2874
+
2875
+ for idx_val in sorted(all_indexes):
2876
+ cells: list[tuple[int, Any]] = []
2877
+
2878
+ # Alternative
2879
+ cells.append((col_alt, alt))
2880
+
2881
+ # Entity dimension values
2882
+ for i, col in enumerate(entity_data_cols):
2883
+ if i < len(entity_byname):
2884
+ cells.append((col, str(_to_native(entity_byname[i]))))
2885
+
2886
+ # Extra entity columns
2887
+ if spec.extra_entity_columns:
2888
+ extras = _get_extra_columns_for_entity(
2889
+ entity_byname, entity_class, spec, db_contents
2890
+ )
2891
+ for i, col in enumerate(extra_entity_data_cols):
2892
+ if i < len(extras):
2893
+ cells.append((col, extras[i]))
2894
+
2895
+ # Direction/filter value
2896
+ if filter_data_col is not None and direction is not None:
2897
+ cells.append((filter_data_col, direction))
2898
+
2899
+ # Unpack index value
2900
+ if unpack_data_col is not None:
2901
+ cells.append((unpack_data_col, idx_val))
2902
+
2903
+ # Entity existence
2904
+ if ea_data_col is not None:
2905
+ ea_key = (entity_class, entity_byname, alt)
2906
+ ea_val = db_contents.entity_alternatives.get(ea_key)
2907
+ if ea_val is not None:
2908
+ cells.append((ea_data_col, ea_val))
2909
+
2910
+ # Parameter values from maps
2911
+ for pname in spec.parameter_names:
2912
+ m = param_maps.get(pname)
2913
+ if m is not None:
2914
+ for mi, mv in zip(m.indexes, m.values):
2915
+ if str(_to_native(mi)) == idx_val:
2916
+ cells.append((param_col_map[pname], _to_native(mv)))
2917
+ break
2918
+
2919
+ # Pre-EA scalar params (these are not maps)
2920
+ for pname in spec.pre_ea_params:
2921
+ key = (entity_class, entity_byname, pname, alt)
2922
+ value = db_contents.parameter_values.get(key)
2923
+ if value is not None and _is_scalar(value):
2924
+ cells.append((param_col_map[pname], _to_native(value)))
2925
+
2926
+ data_rows.append(cells)
2927
+
2928
+
2929
+ # ---------------------------------------------------------------------------
2930
+ # v2 Periodic sheet
2931
+ # ---------------------------------------------------------------------------
2932
+
2933
+
2934
+ def write_periodic_sheet_v2(
2935
+ ws: Worksheet,
2936
+ spec: SheetSpec,
2937
+ db_contents: DatabaseContents,
2938
+ ) -> None:
2939
+ """Write a periodic-layout sheet in the v2 self-describing format.
2940
+
2941
+ Layout:
2942
+ Row 1: navigate | | | description | param descriptions...
2943
+ Row 2: | | | data type | float...
2944
+ Row 3: alternative | entity: X | [filter:] index: period | parameter | param names...
2945
+ Row 4+: data
2946
+ """
2947
+ # Determine index column name
2948
+ index_col_name = _find_index_column_name(spec, db_contents, default="period")
2949
+
2950
+ n_entity_cols = len(spec.entity_columns)
2951
+ n_dims = n_entity_cols
2952
+
2953
+ # Build left-side columns for row 3
2954
+ left_cols: list[str] = ["alternative"]
2955
+ entity_label = _build_entity_def_label(spec)
2956
+ left_cols.append(entity_label)
2957
+
2958
+ # For multi-dim, add ALL individual dim name columns after entity label
2959
+ if n_dims > 1:
2960
+ for dim_name in spec.entity_columns:
2961
+ left_cols.append(dim_name)
2962
+
2963
+ # Direction/filter column
2964
+ filter_label = _build_filter_label(spec)
2965
+ if filter_label:
2966
+ left_cols.append(filter_label)
2967
+
2968
+ # Index column (period)
2969
+ left_cols.append(f"index: {index_col_name}")
2970
+ index_col_pos = len(left_cols) # 1-based
2971
+
2972
+ def_col = len(left_cols) + 1
2973
+
2974
+ # Right-side columns
2975
+ right_cols: list[str] = ["parameter"]
2976
+ right_cols.extend(spec.parameter_names)
2977
+
2978
+ # --- Row 1: navigate + descriptions ---
2979
+ ws.cell(row=1, column=1, value="navigate")
2980
+ ws.cell(row=1, column=def_col, value="description")
2981
+ for i, pname in enumerate(spec.parameter_names):
2982
+ col = def_col + 1 + i
2983
+ desc = spec.descriptions.get(pname)
2984
+ if not desc:
2985
+ desc = _get_param_description(pname, spec.entity_classes, db_contents)
2986
+ if desc:
2987
+ ws.cell(row=1, column=col, value=desc)
2988
+
2989
+ # --- Row 2: data types ---
2990
+ ws.cell(row=2, column=def_col, value="data type")
2991
+ for i, pname in enumerate(spec.parameter_names):
2992
+ col = def_col + 1 + i
2993
+ dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="periodic")
2994
+ ws.cell(row=2, column=col, value=dtype)
2995
+
2996
+ # --- Optional row 3: defaults (only when at least one param has one) ---
2997
+ param_defaults = {
2998
+ pname: _get_param_default_string(pname, spec.entity_classes, db_contents)
2999
+ for pname in spec.parameter_names
3000
+ }
3001
+ has_default_row = any(v is not None for v in param_defaults.values())
3002
+ if has_default_row:
3003
+ default_row = 3
3004
+ ws.cell(row=default_row, column=def_col, value="default")
3005
+ for i, pname in enumerate(spec.parameter_names):
3006
+ col = def_col + 1 + i
3007
+ dv = param_defaults[pname]
3008
+ if dv is not None:
3009
+ ws.cell(row=default_row, column=col, value=dv)
3010
+ def_row = 4
3011
+ else:
3012
+ def_row = 3
3013
+ data_start_row = def_row + 1
3014
+
3015
+ # --- Definition row ---
3016
+ for col_idx, label in enumerate(left_cols, start=1):
3017
+ ws.cell(row=def_row, column=col_idx, value=label)
3018
+ for col_idx, label in enumerate(right_cols, start=def_col):
3019
+ ws.cell(row=def_row, column=col_idx, value=label)
3020
+
3021
+ # --- Map column positions ---
3022
+ col_alt = 1
3023
+
3024
+ if n_dims <= 1:
3025
+ entity_data_cols = [2]
3026
+ next_col = 3
3027
+ else:
3028
+ entity_data_cols = list(range(3, 3 + n_dims))
3029
+ next_col = 3 + n_dims
3030
+
3031
+ filter_data_col: int | None = None
3032
+ if filter_label:
3033
+ filter_data_col = next_col
3034
+ next_col += 1
3035
+
3036
+ index_data_col = next_col
3037
+ next_col += 1
3038
+
3039
+ assert next_col == def_col, f"Column mismatch: expected def_col={def_col}, got {next_col}"
3040
+
3041
+ param_data_start = def_col + 1
3042
+ param_col_map: dict[str, int] = {}
3043
+ for i, pname in enumerate(spec.parameter_names):
3044
+ param_col_map[pname] = param_data_start + i
3045
+
3046
+ # --- Collect data rows ---
3047
+ data_rows: list[list[tuple[int, Any]]] = []
3048
+
3049
+ for entity_class in spec.entity_classes:
3050
+ entities = db_contents.entities.get(entity_class, [])
3051
+ direction = _get_direction(entity_class, spec)
3052
+
3053
+ for entity in entities:
3054
+ entity_byname = entity["entity_byname"]
3055
+ alts = _find_alternatives_for_entity(
3056
+ entity_class, entity_byname, spec, db_contents
3057
+ )
3058
+
3059
+ for alt in alts:
3060
+ all_indexes: set[str] = set()
3061
+ param_maps: dict[str, Map] = {}
3062
+ param_arrays: dict[str, list[str]] = {}
3063
+ param_scalars: dict[str, Any] = {}
3064
+ empty_collections: list[str] = []
3065
+
3066
+ for pname in spec.parameter_names:
3067
+ key = (entity_class, entity_byname, pname, alt)
3068
+ value = db_contents.parameter_values.get(key)
3069
+ if value is None:
3070
+ continue
3071
+ if _is_map(value):
3072
+ is_nested = any(_is_map(mv) for mv in value.values)
3073
+ if is_nested:
3074
+ continue
3075
+ # Skip time-indexed maps on periodic sheets — unless
3076
+ # the entity class defines time structure itself
3077
+ if _is_time_indexed_map(value) and entity_class not in _time_structure_classes:
3078
+ continue
3079
+ if not value.indexes:
3080
+ # Empty Map carries no rows in the positional
3081
+ # layout — record it for the sentinel row below
3082
+ # so the empty value survives the round-trip
3083
+ # instead of vanishing into "no value".
3084
+ empty_collections.append(pname)
3085
+ continue
3086
+ param_maps[pname] = value
3087
+ for idx in value.indexes:
3088
+ all_indexes.add(str(_to_native(idx)))
3089
+ elif _is_array(value):
3090
+ period_names = [str(_to_native(v)) for v in value.values]
3091
+ if not period_names:
3092
+ # Empty Array — same problem as the empty Map: a
3093
+ # positional array of zero elements emits no rows
3094
+ # and is indistinguishable from an absent value
3095
+ # unless we mark it explicitly. An absent value
3096
+ # inherits the lower-ranked alternative; an empty
3097
+ # override (e.g. solve.contains_solves = []) must
3098
+ # NOT, so the distinction is load-bearing.
3099
+ empty_collections.append(pname)
3100
+ continue
3101
+ param_arrays[pname] = period_names
3102
+ elif _is_scalar(value):
3103
+ # Scalar values on a periodic-layout sheet — captured
3104
+ # so the entity-alt still produces an export row even
3105
+ # when no Map/Array index is present. Necessary for
3106
+ # split-params sheets like 'solve' that mix scalars
3107
+ # (solve_mode, solver, ...) with 1d-map params
3108
+ # (solver_arguments).
3109
+ #
3110
+ # BUT skip params that also live on a sibling constant
3111
+ # sheet (e.g. node.existing on node_c + node_p): the
3112
+ # constant writer already emits the scalar there, so a
3113
+ # scalar-only row here would duplicate the value and
3114
+ # collide on import round-trip.
3115
+ if pname in spec.scalar_params_on_constant_sibling:
3116
+ continue
3117
+ param_scalars[pname] = _to_native(value)
3118
+
3119
+ # If no Map indexes but we have Array values, use the array
3120
+ # values as indexes (they contain period names)
3121
+ if not all_indexes and param_arrays:
3122
+ for arr_vals in param_arrays.values():
3123
+ all_indexes.update(arr_vals)
3124
+
3125
+ # Build dict lookups from Maps for O(1) access (avoids O(n²))
3126
+ param_map_dicts: dict[str, dict[str, Any]] = {}
3127
+ for pname, m in param_maps.items():
3128
+ param_map_dicts[pname] = {
3129
+ str(_to_native(mi)): _to_native(mv)
3130
+ for mi, mv in zip(m.indexes, m.values)
3131
+ }
3132
+
3133
+ def _build_left_cells(idx_val: str | None) -> list[tuple[int, Any]]:
3134
+ cells: list[tuple[int, Any]] = [(col_alt, alt)]
3135
+ for i, col in enumerate(entity_data_cols):
3136
+ if i < len(entity_byname):
3137
+ cells.append((col, str(_to_native(entity_byname[i]))))
3138
+ if filter_data_col is not None and direction is not None:
3139
+ cells.append((filter_data_col, direction))
3140
+ if idx_val is not None and idx_val != "":
3141
+ cells.append((index_data_col, idx_val))
3142
+ return cells
3143
+
3144
+ for idx_val in sorted(all_indexes):
3145
+ cells = _build_left_cells(idx_val)
3146
+
3147
+ for pname in spec.parameter_names:
3148
+ md = param_map_dicts.get(pname)
3149
+ if md is not None:
3150
+ val = md.get(idx_val)
3151
+ if val is not None:
3152
+ cells.append((param_col_map[pname], val))
3153
+ elif pname in param_arrays:
3154
+ if idx_val in param_arrays[pname]:
3155
+ # Round-trip preservation: emit the period
3156
+ # name as the cell value (not "TRUE") so the
3157
+ # importer reconstructs a Map<period,period>.
3158
+ # The engine reads Array values via
3159
+ # ``param_value.values`` and treats each
3160
+ # value as a period token (see
3161
+ # ``_solve_config.periods_to_tuples``); using
3162
+ # the period name keeps that contract intact
3163
+ # even though the type morphs Array→Map on
3164
+ # round-trip. Axis-enum vocab is built from
3165
+ # *both* map keys and array values
3166
+ # (``_build_period_vocab``), so the period
3167
+ # token survives in either representation.
3168
+ cells.append((param_col_map[pname], idx_val))
3169
+
3170
+ data_rows.append(cells)
3171
+
3172
+ # Emit a scalar-only row when the entity-alt has scalar
3173
+ # parameter values that no period-indexed row carries.
3174
+ # Without this, periodic sheets that bundle scalar params
3175
+ # (e.g. the split-params 'solve' sheet's solve_mode/solver/
3176
+ # contains_solves) silently drop those values on entities
3177
+ # that never set the periodic params.
3178
+ if param_scalars:
3179
+ cells = _build_left_cells(None)
3180
+ for pname, val in param_scalars.items():
3181
+ cells.append((param_col_map[pname], val))
3182
+ data_rows.append(cells)
3183
+
3184
+ # Emit a sentinel row for empty Array/Map parameter values.
3185
+ # The index cell carries EMPTY_COLLECTION_SENTINEL so the
3186
+ # importer can rebuild a zero-length collection (see
3187
+ # write_self_describing_to_db._group_map_records); the
3188
+ # data-type row already distinguishes Array from Map.
3189
+ if empty_collections:
3190
+ cells = _build_left_cells(EMPTY_COLLECTION_SENTINEL)
3191
+ for pname in empty_collections:
3192
+ cells.append((param_col_map[pname], EMPTY_COLLECTION_SENTINEL))
3193
+ data_rows.append(cells)
3194
+
3195
+ # --- Sort rows ---
3196
+ data_rows.sort(key=lambda r: tuple(
3197
+ str(v) if v is not None else "" for _, v in r
3198
+ ))
3199
+
3200
+ # --- Write data rows ---
3201
+ for row_idx, row_cells in enumerate(data_rows, start=data_start_row):
3202
+ for col, value in row_cells:
3203
+ if value is not None:
3204
+ ws.cell(row=row_idx, column=col, value=value)
3205
+
3206
+ # --- Write parameter reference section ---
3207
+ last_data_col = def_col + len(right_cols) - 1
3208
+ _write_param_reference(
3209
+ ws, last_data_col + 1, spec, db_contents, header_row=def_row,
3210
+ n_data_rows=len(data_rows), layout="periodic",
3211
+ shown_params=list(spec.parameter_names),
3212
+ )
3213
+
3214
+ # --- Formatting ---
3215
+ n_extra = (
3216
+ (1 if filter_label else 0)
3217
+ + 1 # index column
3218
+ )
3219
+ format_periodic_sheet_v2(
3220
+ ws, n_entity_cols, n_extra, def_col, {index_col_pos},
3221
+ last_data_col, def_row=def_row,
3222
+ )
3223
+ add_navigate_link(ws)
3224
+ auto_column_width(ws, min_param_width=_min_param_width,
3225
+ non_param_width=_non_param_width,
3226
+ def_col_width=_def_col_width,
3227
+ index_col_width=_index_col_width,
3228
+ header_row=def_row, def_col=def_col,
3229
+ index_cols={index_col_pos},
3230
+ last_data_col=last_data_col)
3231
+
3232
+ # --- Lock metadata ---
3233
+ _lock_metadata_cells(
3234
+ ws,
3235
+ meta_rows=def_row,
3236
+ meta_cols=def_col,
3237
+ data_start_row=data_start_row,
3238
+ data_start_col=1,
3239
+ )
3240
+
3241
+
3242
+ # ---------------------------------------------------------------------------
3243
+ # v2 Timeseries sheet
3244
+ # ---------------------------------------------------------------------------
3245
+
3246
+
3247
+ def write_timeseries_sheet_v2(
3248
+ ws: Worksheet,
3249
+ spec: SheetSpec,
3250
+ db_contents: DatabaseContents,
3251
+ ) -> None:
3252
+ """Write a transposed timeseries-layout sheet in the v2 self-describing format.
3253
+
3254
+ All _t sheets are transposed: time index in column A, data in columns C+.
3255
+ Header rows follow the canonical order, applied to whichever dimensions
3256
+ are needed:
3257
+
3258
+ description → data_type → entity → entity_dims → filter →
3259
+ alternative → parameter
3260
+
3261
+ For single-param sheets, description/data_type/parameter collapse into
3262
+ a single 'triplet row' in B1 using ``|`` separator.
3263
+ """
3264
+ # ── Collect data columns ──────────────────────────────────────
3265
+ columns: list[tuple[str, tuple, str, str, Map]] = []
3266
+ all_time_indexes: set[str] = set()
3267
+
3268
+ for entity_class in spec.entity_classes:
3269
+ entities = db_contents.entities.get(entity_class, [])
3270
+ for entity in entities:
3271
+ entity_byname = entity["entity_byname"]
3272
+ for pname in spec.parameter_names:
3273
+ for alt in db_contents.alternatives:
3274
+ key = (entity_class, entity_byname, pname, alt)
3275
+ value = db_contents.parameter_values.get(key)
3276
+ if value is not None and _is_map(value):
3277
+ if not _is_time_indexed_map(value):
3278
+ continue
3279
+ columns.append(
3280
+ (entity_class, entity_byname, pname, alt, value)
3281
+ )
3282
+ for idx in value.indexes:
3283
+ all_time_indexes.add(str(_to_native(idx)))
3284
+
3285
+ columns.sort(key=lambda c: (c[3], c[1], c[2]))
3286
+ sorted_times = sorted(all_time_indexes)
3287
+
3288
+ single_param = len(spec.parameter_names) == 1
3289
+ n_entity_dims = len(spec.entity_columns)
3290
+ has_direction = spec.direction_column is not None
3291
+ _build_entity_def_label(spec)
3292
+
3293
+ # ── Build header rows following canonical order ───────────────
3294
+ row_types: dict[int, str] = {}
3295
+ cur_row = 1
3296
+
3297
+ # INFO row for all transposed sheets
3298
+ info_text = _info_rows.get(spec.sheet_name)
3299
+ if not info_text:
3300
+ info_text = "INFO: Add new data columns as needed by right clicking on the column name and then selecting 'Insert columns before'."
3301
+ ws.cell(row=cur_row, column=2, value=info_text)
3302
+ cur_row += 1
3303
+
3304
+ if single_param:
3305
+ # Triplet row: combined parameter | data_type | description
3306
+ default_param = spec.parameter_names[0]
3307
+ dtype = _get_param_data_type(default_param, spec.entity_classes, db_contents, layout="timeseries")
3308
+ desc = _get_param_description(default_param, spec.entity_classes, db_contents) or ""
3309
+ triplet = f"parameter: {default_param} | data type: {dtype}"
3310
+ if desc:
3311
+ triplet += f" | description: {desc}"
3312
+ default_str = _get_param_default_string(default_param, spec.entity_classes, db_contents)
3313
+ if default_str is not None:
3314
+ triplet += f" | default: {default_str}"
3315
+ ws.cell(row=cur_row, column=2, value=triplet)
3316
+ row_types[cur_row] = "param_info"
3317
+ cur_row += 1
3318
+
3319
+ # data type row (multi-param only — the single-param branch carries the
3320
+ # data type inside its triplet). Without this, the reader has no leaf
3321
+ # dtype for a multi-param _t sheet (e.g. node_t) and defaults to string,
3322
+ # so float time-series round-trip as string-valued Maps.
3323
+ if not single_param:
3324
+ ws.cell(row=cur_row, column=2, value="data type")
3325
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
3326
+ dtype = _get_param_data_type(pname, [ec], db_contents, layout="timeseries")
3327
+ ws.cell(row=cur_row, column=col_idx, value=dtype)
3328
+ row_types[cur_row] = "data type"
3329
+ cur_row += 1
3330
+
3331
+ # entity row — simplified label (just dimension names, not full mapping)
3332
+ if n_entity_dims <= 1:
3333
+ simple_label = f"entity: {spec.entity_columns[0]}"
3334
+ ws.cell(row=cur_row, column=2, value=simple_label)
3335
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
3336
+ if byname:
3337
+ ws.cell(row=cur_row, column=col_idx, value=str(_to_native(byname[0])))
3338
+ row_types[cur_row] = "entity"
3339
+ cur_row += 1
3340
+ else:
3341
+ # Multi-dim: one row per dimension
3342
+ for dim_idx in range(n_entity_dims):
3343
+ if dim_idx == 0:
3344
+ ws.cell(row=cur_row, column=2, value=f"entity: {', '.join(spec.entity_columns)}")
3345
+ else:
3346
+ ws.cell(row=cur_row, column=2, value=spec.entity_columns[dim_idx])
3347
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
3348
+ if dim_idx < len(byname):
3349
+ ws.cell(row=cur_row, column=col_idx, value=str(_to_native(byname[dim_idx])))
3350
+ row_types[cur_row] = "entity"
3351
+ cur_row += 1
3352
+
3353
+ # filter row (for merged classes with direction) — simple label + dropdown
3354
+ filter_row_num = None
3355
+ if has_direction:
3356
+ filter_row_num = cur_row
3357
+ ws.cell(row=cur_row, column=2, value=spec.direction_column)
3358
+ filter_values = sorted(set(spec.direction_map.values()))
3359
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
3360
+ direction = _get_direction(ec, spec)
3361
+ if direction:
3362
+ ws.cell(row=cur_row, column=col_idx, value=direction)
3363
+ row_types[cur_row] = "entity"
3364
+ cur_row += 1
3365
+
3366
+ # alternative row
3367
+ ws.cell(row=cur_row, column=2, value="alternative")
3368
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
3369
+ ws.cell(row=cur_row, column=col_idx, value=alt)
3370
+ row_types[cur_row] = "alternative"
3371
+ cur_row += 1
3372
+
3373
+ # parameter row (multi-param only)
3374
+ param_row_num = None
3375
+ if not single_param:
3376
+ param_row_num = cur_row
3377
+ ws.cell(row=cur_row, column=2, value="parameter")
3378
+ for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
3379
+ ws.cell(row=cur_row, column=col_idx, value=pname)
3380
+ row_types[cur_row] = "parameter"
3381
+ cur_row += 1
3382
+
3383
+ # default row (multi-param only, when at least one column has one)
3384
+ column_defaults = [
3385
+ _get_param_default_string(pname, [ec], db_contents)
3386
+ for (ec, _byname, pname, _alt, _m) in columns
3387
+ ]
3388
+ if any(d is not None for d in column_defaults):
3389
+ ws.cell(row=cur_row, column=2, value="default")
3390
+ for col_idx, default_str in enumerate(column_defaults, start=3):
3391
+ if default_str is not None:
3392
+ ws.cell(row=cur_row, column=col_idx, value=default_str)
3393
+ row_types[cur_row] = "default"
3394
+ cur_row += 1
3395
+
3396
+ n_header_rows = cur_row - 1
3397
+
3398
+ # index: time label on column A of the last header row
3399
+ ws.cell(row=n_header_rows, column=1, value="index: time")
3400
+
3401
+ # ── Write time data rows ─────────────────────────────────────
3402
+ col_index_maps: list[dict[str, Any]] = []
3403
+ for ec, byname, pname, alt, m in columns:
3404
+ idx_map: dict[str, Any] = {}
3405
+ for mi, mv in zip(m.indexes, m.values):
3406
+ idx_map[str(_to_native(mi))] = _to_native(mv)
3407
+ col_index_maps.append(idx_map)
3408
+
3409
+ for time_row_idx, time_val in enumerate(sorted_times):
3410
+ row = n_header_rows + 1 + time_row_idx
3411
+ ws.cell(row=row, column=1, value=time_val)
3412
+ for col_idx, idx_map in enumerate(col_index_maps):
3413
+ value = idx_map.get(time_val)
3414
+ if value is not None:
3415
+ ws.cell(row=row, column=col_idx + 3, value=value)
3416
+
3417
+ add_navigate_link(ws)
3418
+
3419
+ # ── Reference section (two-column gap, no triplet) ──────────
3420
+ last_data_col = max(len(columns) + 2, 3)
3421
+ # Extend coloring to include the two-column gap before convenience
3422
+ color_end_col = last_data_col + 2
3423
+ _write_param_reference(
3424
+ ws, color_end_col, spec, db_contents, header_row=n_header_rows,
3425
+ n_data_rows=len(sorted_times), layout="timeseries",
3426
+ shown_params=list(spec.parameter_names),
3427
+ )
3428
+
3429
+ # ── Dropdowns ─────────────────────────────────────────────────
3430
+ from openpyxl.worksheet.datavalidation import DataValidation
3431
+ from openpyxl.utils import get_column_letter
3432
+
3433
+ # Parameter dropdown for multi-param transposed sheets
3434
+ if param_row_num is not None and len(spec.parameter_names) > 1:
3435
+ param_list = ",".join([""] + list(spec.parameter_names))
3436
+ dv = DataValidation(
3437
+ type="list",
3438
+ formula1=f'"{param_list}"',
3439
+ allow_blank=True,
3440
+ showDropDown=False,
3441
+ )
3442
+ dv.error = "Select a parameter"
3443
+ dv.errorTitle = "Parameter"
3444
+ first_col = get_column_letter(3)
3445
+ last_col = get_column_letter(color_end_col)
3446
+ dv.sqref = f"{first_col}{param_row_num}:{last_col}{param_row_num}"
3447
+ ws.add_data_validation(dv)
3448
+
3449
+ # Filter dropdown for merged classes (input/output etc.)
3450
+ if filter_row_num is not None and has_direction:
3451
+ filter_values = sorted(set(spec.direction_map.values()))
3452
+ filter_list = ",".join([""] + filter_values)
3453
+ dv_filter = DataValidation(
3454
+ type="list",
3455
+ formula1=f'"{filter_list}"',
3456
+ allow_blank=True,
3457
+ showDropDown=False,
3458
+ )
3459
+ dv_filter.error = f"Select: {', '.join(filter_values)}"
3460
+ dv_filter.errorTitle = spec.direction_column
3461
+ first_col = get_column_letter(3)
3462
+ last_col = get_column_letter(color_end_col)
3463
+ dv_filter.sqref = f"{first_col}{filter_row_num}:{last_col}{filter_row_num}"
3464
+ ws.add_data_validation(dv_filter)
3465
+
3466
+ # ── Formatting (extend to color_end_col to cover gap) ─────────
3467
+ format_timeseries_sheet_v2(ws, n_header_rows, single_param=single_param,
3468
+ row_types=row_types, last_data_col=color_end_col)
3469
+
3470
+ # Column A: index fill (green, consistent with other index columns)
3471
+ from openpyxl.formatting.rule import CellIsRule
3472
+ data_end = max(n_header_rows + len(sorted_times), n_header_rows + 1) + 100
3473
+ ws.cell(row=n_header_rows, column=1).fill = FILL_INDEX_HEADER
3474
+ ws.conditional_formatting.add(
3475
+ f"A{n_header_rows + 1}:A{data_end}",
3476
+ CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'], fill=FILL_INDEX_DATA),
3477
+ )
3478
+ # Column B: dark grey definition column
3479
+ ws.conditional_formatting.add(
3480
+ f"B{n_header_rows + 1}:B{data_end}",
3481
+ CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'], fill=FILL_DEF_COL),
3482
+ )
3483
+
3484
+ auto_column_width(ws, min_param_width=_min_param_width,
3485
+ non_param_width=_non_param_width,
3486
+ def_col_width=_def_col_width,
3487
+ index_col_width=_index_col_width,
3488
+ header_row=n_header_rows, def_col=3,
3489
+ last_data_col=last_data_col)
3490
+
3491
+ _lock_metadata_cells(
3492
+ ws, meta_rows=n_header_rows, meta_cols=2,
3493
+ data_start_row=n_header_rows + 1, data_start_col=1,
3494
+ )
3495
+
3496
+
3497
+ # ---------------------------------------------------------------------------
3498
+ # v2 Link sheet
3499
+ # ---------------------------------------------------------------------------
3500
+
3501
+
3502
+ def write_link_sheet_v2(
3503
+ ws: Worksheet,
3504
+ spec: SheetSpec,
3505
+ db_contents: DatabaseContents,
3506
+ ) -> None:
3507
+ """Write a link-only (relationship) sheet in the v2 self-describing format.
3508
+
3509
+ Layout:
3510
+ Row 1: navigate
3511
+ Row 2: 'entity: X, Y' | dim1_name | dim2_name
3512
+ Row 3+: (empty) | dim1_val | dim2_val
3513
+ """
3514
+ entity_class = spec.entity_classes[0] if spec.entity_classes else None
3515
+
3516
+ # Row 1: navigate
3517
+ ws.cell(row=1, column=1, value="navigate")
3518
+
3519
+ # Row 2: entity definition + dimension headers
3520
+ entity_label = _build_entity_def_label(spec)
3521
+ ws.cell(row=2, column=1, value=entity_label)
3522
+ for col_idx, col_name in enumerate(spec.entity_columns, start=2):
3523
+ ws.cell(row=2, column=col_idx, value=col_name)
3524
+
3525
+ if not entity_class:
3526
+ format_link_sheet_v2(ws)
3527
+ add_navigate_link(ws)
3528
+ auto_column_width(ws, non_param_width=_non_param_width,
3529
+ def_col_width=_def_col_width,
3530
+ index_col_width=_index_col_width)
3531
+ return
3532
+
3533
+ # Collect and sort entity rows
3534
+ entities = db_contents.entities.get(entity_class, [])
3535
+ rows: list[tuple] = []
3536
+ for entity in entities:
3537
+ byname = entity["entity_byname"]
3538
+ rows.append(tuple(str(_to_native(v)) for v in byname))
3539
+
3540
+ rows.sort()
3541
+
3542
+ for row_idx, row_data in enumerate(rows, start=3):
3543
+ for col_idx, value in enumerate(row_data, start=2):
3544
+ ws.cell(row=row_idx, column=col_idx, value=value)
3545
+
3546
+ format_link_sheet_v2(ws)
3547
+ add_navigate_link(ws)
3548
+ auto_column_width(ws, non_param_width=_non_param_width,
3549
+ def_col_width=_def_col_width,
3550
+ index_col_width=_index_col_width)
3551
+
3552
+ # --- Lock metadata ---
3553
+ _lock_metadata_cells(
3554
+ ws,
3555
+ meta_rows=2,
3556
+ meta_cols=1,
3557
+ data_start_row=3,
3558
+ data_start_col=2,
3559
+ )