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,672 @@
1
+ """Derive per-column output metadata (unit + semantics) from transforms.
2
+
3
+ The output stage turns LP variables and input parameters into ~95 processed
4
+ tables. Hand-authoring metadata per column would be a slog and rot on the
5
+ next schema change. Instead we *derive* it: every annual/extensive output is
6
+ produced by one of a small, named set of transforms (see :class:`Transform`),
7
+ and the displayed unit + temporal semantics fall out of the transform applied
8
+ to a base quantity.
9
+
10
+ This is the "derive from transforms" layer. ``annualize_dt_to_d`` (the single
11
+ dt→d aggregation helper all annual outputs now route through, see
12
+ ``_annualize.py``) is the runtime embodiment of the ``*_ANNUAL`` transforms;
13
+ this module is the static counterpart that labels the resulting columns.
14
+
15
+ A per-output declaration (:data:`OUTPUT_TRANSFORM`) maps an output key to the
16
+ transform its (non-dimension) columns underwent. :func:`derive_column_meta`
17
+ expands that to one :class:`ColumnMeta` per column, generating a templated
18
+ tooltip and attaching any per-column ``formula`` override.
19
+
20
+ Coverage is enforced by ``tests/test_output_column_metadata.py`` via a ratchet:
21
+ new processed outputs may not land undocumented.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from enum import Enum
28
+
29
+
30
+ class Semantics(str, Enum):
31
+ """The kind of quantity a column holds — the field that disambiguates
32
+ a per-timestep rate from a full-year-annualized total."""
33
+
34
+ INSTANTANEOUS = "instantaneous" # a rate at the timestep (MW). Integrate for energy.
35
+ PER_STEP = "per_step" # already integrated over the step's duration (cost_dt).
36
+ PER_PERIOD = "per_period" # summed over the period, as-sampled (NOT scaled to a year).
37
+ ANNUALIZED = "annualized" # scaled to a full-year equivalent (÷ period_share_of_year).
38
+ HORIZON = "horizon" # undiscounted total over the represented horizon (×years_represented).
39
+ DISCOUNTED = "discounted" # NPV over the horizon (inflation + years_represented).
40
+ AVERAGE = "average" # time-average over the period (÷ period_hours).
41
+ RATIO = "ratio" # dimensionless (share, capacity factor).
42
+ COUNT = "count" # event count (e.g. start-ups), annualized.
43
+ LEVEL = "level" # a stock/snapshot (capacity, storage state) — not summed.
44
+ PRICE = "price" # marginal value / shadow price (dual).
45
+ DIMENSION = "dimension" # index/key column, not a measure.
46
+
47
+
48
+ # Column names that are always index/key dimensions, never measures.
49
+ DIMENSION_COLUMNS: frozenset[str] = frozenset(
50
+ {"solve", "period", "time", "scenario", "step", "year", "group", "node",
51
+ "unit", "connection", "process", "source", "sink", "commodity", "reserve",
52
+ "upDown", "category"}
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Transform:
58
+ """A named output transform → the (unit, semantics) it yields.
59
+
60
+ ``unit`` is the *displayed* unit string (already including ``/a`` where the
61
+ quantity is annualized), derived by the simple algebra noted in ``algebra``
62
+ (e.g. MW · h / period_share → MWh/a). Keeping the algebra in a comment
63
+ rather than a full dimensional-analysis engine is deliberate: the transform
64
+ set is tiny and stable, so an explicit table is clearer and just as safe.
65
+ """
66
+
67
+ unit: str
68
+ semantics: Semantics
69
+ tooltip: str # short, one-line (plot/CSV/hover).
70
+ description: str = "" # optional longer form (docs/datapackage); falls back to tooltip.
71
+ algebra: str = ""
72
+
73
+
74
+ # The transform catalog. Each entry is the result of applying one output-stage
75
+ # transform to its base quantity. Names mirror the code paths in out_*/calc_*.
76
+ RATE = Transform(
77
+ "MW", Semantics.INSTANTANEOUS,
78
+ "Power at the timestep (a rate). Multiply by step_duration for energy.",
79
+ algebra="v_flow",
80
+ )
81
+ ENERGY_ANNUAL = Transform(
82
+ "MWh/a", Semantics.ANNUALIZED,
83
+ "Energy, scaled from the sampled timeline to a full-year equivalent.",
84
+ algebra="MW · step_duration · timestep_weight / period_share",
85
+ )
86
+ ENERGY_PERIOD = Transform(
87
+ "MWh", Semantics.PER_PERIOD,
88
+ "Energy summed over the sampled timeline (NOT scaled to a full year).",
89
+ algebra="MW · step_duration · timestep_weight (no /period_share)",
90
+ )
91
+ PREENERGY_ANNUAL = Transform(
92
+ "MWh/a", Semantics.ANNUALIZED,
93
+ "Energy (already per-step MWh), scaled to a full-year equivalent.",
94
+ algebra="MWh_step · timestep_weight / period_share",
95
+ )
96
+ MONEY_PERSTEP = Transform(
97
+ "CUR", Semantics.PER_STEP,
98
+ "Cost incurred during the timestep (step_duration and weight already applied).",
99
+ algebra="price · flow · step_duration · timestep_weight",
100
+ )
101
+ MONEY_ANNUAL = Transform(
102
+ "M CUR/a", Semantics.ANNUALIZED,
103
+ "Cost, scaled from the sampled timeline to a full-year equivalent.",
104
+ algebra="Σ cost_dt / period_share / 1e6",
105
+ )
106
+ MONEY_DISCOUNTED = Transform(
107
+ "M CUR", Semantics.DISCOUNTED,
108
+ "Net present cost over the horizon (inflation × years_represented applied).",
109
+ algebra="annual_cost · inflation_factor · years_represented",
110
+ )
111
+ COST_ENTITY_ANNUAL = Transform(
112
+ "M CUR/a", Semantics.ANNUALIZED,
113
+ "Per-entity annualized cost for this category (full-year equivalent).",
114
+ description="Annualized cost of one (entity, period) cell for this "
115
+ "category — the system annualized cost collapsed per entity "
116
+ "rather than summed system-wide. Fuel/commodity cost is "
117
+ "attributed to the consuming process (the unit/connection that "
118
+ "draws the commodity, not the source node); CO2 cost is summed "
119
+ "over every priced group the process touches. Summing a "
120
+ "category over all entities reproduces the system summary "
121
+ "(annualized_costs_d_p).",
122
+ algebra="Σ entity_cost_dt / period_share / 1e6",
123
+ )
124
+ COST_ENTITY_DISCOUNTED = Transform(
125
+ "M CUR", Semantics.DISCOUNTED,
126
+ "Per-entity net present cost for this category over the horizon.",
127
+ description="Net present (discounted) cost of one (entity, period) cell "
128
+ "for this category — the system discounted cost collapsed per "
129
+ "entity rather than summed system-wide. Fuel/commodity cost is "
130
+ "attributed to the consuming process (the unit/connection that "
131
+ "draws the commodity, not the source node); CO2 cost is summed "
132
+ "over every priced group the process touches. Summing a "
133
+ "category over all entities reproduces the system summary "
134
+ "(costs_discounted_d_p).",
135
+ algebra="annual_entity_cost · inflation_factor · years_represented",
136
+ )
137
+ EMISSION_ANNUAL = Transform(
138
+ "t/a", Semantics.ANNUALIZED,
139
+ "Emissions, scaled from the sampled timeline to a full-year equivalent.",
140
+ algebra="t_step · timestep_weight / period_share",
141
+ )
142
+ COUNT_ANNUAL = Transform(
143
+ "units/a", Semantics.COUNT,
144
+ "Event count (e.g. start-ups), scaled to a full-year equivalent.",
145
+ algebra="count_step · timestep_weight / period_share",
146
+ )
147
+ AVERAGE_MW = Transform(
148
+ "MW", Semantics.AVERAGE,
149
+ "Time-average power held over the period.",
150
+ algebra="Σ(MW · step_duration) / period_hours",
151
+ )
152
+ RATIO = Transform(
153
+ "", Semantics.RATIO,
154
+ "Dimensionless ratio (share / capacity factor).",
155
+ )
156
+ ENERGY_PERSTEP = Transform(
157
+ "MWh", Semantics.PER_STEP,
158
+ "Energy within the timestep (already per-step MWh, not a rate).",
159
+ )
160
+ RAMP = Transform(
161
+ "MW", Semantics.INSTANTANEOUS,
162
+ "Change in flow between consecutive timesteps (a ramp).",
163
+ algebra="flow[t] − flow[t-1]",
164
+ )
165
+ CAPACITY_MW = Transform(
166
+ "MW", Semantics.LEVEL,
167
+ "Installed/usable capacity (a stock that holds over the period, not a flow).",
168
+ algebra="existing + invested − divested",
169
+ )
170
+ CAPACITY_MWH = Transform(
171
+ "MWh", Semantics.LEVEL,
172
+ "Storage energy capacity (a stock).",
173
+ algebra="existing + invested − divested",
174
+ )
175
+ STORAGE_STATE = Transform(
176
+ "MWh", Semantics.LEVEL,
177
+ "Stored energy at the timestep (state of charge).",
178
+ algebra="v_state · unitsize",
179
+ )
180
+ PRICE_ENERGY = Transform(
181
+ "CUR/MWh", Semantics.PRICE,
182
+ "Nodal energy price (shadow price of the node balance constraint).",
183
+ description="Nodal energy price — the shadow price (dual) of the node "
184
+ "balance constraint: the marginal system cost of one more unit "
185
+ "of the commodity at that node and timestep. For a storage "
186
+ "node this is the marginal value of stored energy (the "
187
+ "\"water value\"): a well-defined intertemporal price, but "
188
+ "treat it with care — it can be non-unique when storage sits "
189
+ "at the margin (indifferent between charging and discharging), "
190
+ "spike at empty/full state of charge, differ between the "
191
+ "charge and discharge sides by the round-trip efficiency, and "
192
+ "be shifted by end-of-horizon, rolling and inter-period "
193
+ "(blended-weight) storage linking.",
194
+ algebra="dual(node_balance)",
195
+ )
196
+ EMISSION_MT = Transform(
197
+ "Mt", Semantics.HORIZON,
198
+ "System CO2 emissions, total over the represented horizon (NOT per year).",
199
+ description="System CO2 emissions summed across all periods, each scaled "
200
+ "by its years_represented and converted tonnes→megatonnes — an "
201
+ "undiscounted horizon total, not an annual rate.",
202
+ algebra="Σ_periods(t/a · years_represented) / 1e6",
203
+ )
204
+ FACTOR = Transform(
205
+ "", Semantics.RATIO,
206
+ "Dimensionless discount / inflation factor.",
207
+ )
208
+ ANNUITY = Transform(
209
+ "CUR/MW/a", Semantics.ANNUALIZED,
210
+ "Annualised investment cost per MW of capacity — the cost charged for "
211
+ "each year of the entity's economic lifetime.",
212
+ )
213
+ YEARS = Transform(
214
+ "a", Semantics.COUNT,
215
+ "Calendar years the period represents (annualisation weight).",
216
+ )
217
+ PRICE_RESERVE = Transform(
218
+ "CUR/MW", Semantics.PRICE,
219
+ "Reserve price (dual of the reserve balance) — CUR per MW of reserve held "
220
+ "(= CUR/MWh held for one hour).",
221
+ algebra="dual(reserve_balance)",
222
+ )
223
+ PRICE_CO2 = Transform(
224
+ "CUR/t", Semantics.PRICE,
225
+ "CO2 price (shadow price of the CO2 cap).",
226
+ algebra="dual(co2_cap) / 1000",
227
+ )
228
+ # Investment duals carry the same convention as invest_cost: CUR/kW for
229
+ # units & connections (CUR/kWh for storage nodes).
230
+ DUAL_INVEST_KW = Transform(
231
+ "CUR/kW", Semantics.PRICE,
232
+ "Effective marginal system value of +1 unit of capacity "
233
+ "(same convention as invest_cost).",
234
+ algebra="dual(invest constraint) / entity_unitsize",
235
+ )
236
+ DUAL_INVEST_KWH = Transform(
237
+ "CUR/kWh", Semantics.PRICE,
238
+ "Effective marginal system value of +1 unit of storage capacity "
239
+ "(same convention as invest_cost).",
240
+ )
241
+ INERTIA = Transform(
242
+ "MW·s", Semantics.LEVEL,
243
+ "Inertia provided (rotational energy) at the timestep.",
244
+ algebra="online · unitsize · inertia_constant",
245
+ )
246
+ INERTIA_ANNUAL = Transform(
247
+ "MW·s/a", Semantics.ANNUALIZED,
248
+ "Inertia-constraint slack, scaled to a full-year equivalent.",
249
+ )
250
+ ONLINE_COUNT = Transform(
251
+ "units", Semantics.LEVEL,
252
+ "Number of online units at the timestep.",
253
+ )
254
+ ONLINE_AVG = Transform(
255
+ "units", Semantics.AVERAGE,
256
+ "Average number of online units over the period.",
257
+ )
258
+ RESERVE_AVG = Transform(
259
+ "MW", Semantics.AVERAGE,
260
+ "Average reserve held over the period.",
261
+ )
262
+ ANGLE = Transform(
263
+ "rad", Semantics.LEVEL,
264
+ "DC voltage angle (Bθ formulation; reference node pinned to 0). Radians — "
265
+ "flow = susceptance · Δangle with susceptance = base_MVA / reactance.",
266
+ )
267
+ SLACK_CAP_MARGIN = Transform(
268
+ "MW", Semantics.LEVEL,
269
+ "Capacity-margin slack — per-period MW shortfall (not annualized).",
270
+ )
271
+ # Sentinel: an output that is a pure membership/index set (no measures).
272
+ DIMENSION_TABLE = Transform(
273
+ "", Semantics.DIMENSION,
274
+ "Membership / index set — no measure columns.",
275
+ )
276
+
277
+
278
+ @dataclass(frozen=True)
279
+ class ColumnMeta:
280
+ """Derived metadata for one output column."""
281
+
282
+ name: str
283
+ unit: str
284
+ semantics: Semantics
285
+ tooltip: str # short, one-line.
286
+ long: str = "" # optional longer description (falls back to tooltip).
287
+ formula: str = ""
288
+ docs: str = ""
289
+
290
+ def as_dict(self) -> dict[str, str]:
291
+ return {
292
+ "unit": self.unit,
293
+ "semantics": self.semantics.value,
294
+ "tooltip": self.tooltip,
295
+ "long": self.long,
296
+ "formula": self.formula,
297
+ "docs": self.docs,
298
+ }
299
+
300
+
301
+ # ── Per-column transform maps (for mixed-unit tables) ───────────────────────
302
+ # Some outputs hold columns of different units in one frame. Their
303
+ # OUTPUT_TRANSFORM value is a {column_label: Transform} map instead of a single
304
+ # Transform; derive_column_meta applies the per-column entry (columns absent
305
+ # from the map fall back to a '*' default if present, else dimension).
306
+ _NODEGROUP_GDT_P = {
307
+ '1. Loss of load': ENERGY_PERSTEP,
308
+ '2. VRE generation': ENERGY_PERSTEP,
309
+ '3. Excess load': ENERGY_PERSTEP,
310
+ '4. Curtailed VRE': ENERGY_PERSTEP,
311
+ # Raw inflow level at the timestep — no step_duration applied (an MW
312
+ # level, like nodeGroup/node dt flows), unlike the slack/VRE columns
313
+ # above which are integrated to MWh/step.
314
+ '5. Timestep inflow': RATE,
315
+ '6. Curtailed VRE of potential VRE': RATIO,
316
+ '7. Annualized inflow': PREENERGY_ANNUAL,
317
+ '8. VRE share of demand': RATIO,
318
+ }
319
+ _NODEGROUP_GD_P = {
320
+ '1. Loss of load share': RATIO,
321
+ '2. VRE share of demand': RATIO,
322
+ '3. Excess load share': RATIO,
323
+ '4. Curtailed VRE of demand': RATIO,
324
+ '5. Annualized inflow': PREENERGY_ANNUAL,
325
+ '6. Curtailed VRE of potential VRE': RATIO,
326
+ }
327
+ _FLOWGROUP_GD_P = {
328
+ 'cumulative_flow': ENERGY_PERIOD, # MWh summed over the sample (not annualized)
329
+ 'average_flow': AVERAGE_MW, # MW time-average
330
+ }
331
+
332
+
333
+ # ── Per-output declaration ──────────────────────────────────────────────────
334
+ # Map each output key to the transform its measure columns underwent. One line
335
+ # per output; the per-column expansion + tooltips are generated. Seeded with
336
+ # the high-traffic outputs; extend as coverage grows (the CI ratchet enforces
337
+ # that no NEW output lands here undocumented).
338
+ OUTPUT_TRANSFORM: "dict[str, Transform | dict[str, Transform]]" = {
339
+ # Costs
340
+ "costs_dt_p": MONEY_PERSTEP,
341
+ "annualized_costs_d_p": MONEY_ANNUAL,
342
+ "costs_discounted_d_p": MONEY_DISCOUNTED,
343
+ # Per-entity cost break-down (period level). Category column VALUES are
344
+ # the measures; the entity/period index levels auto-resolve to dimension.
345
+ "cost_unit_annualized_d_ec": COST_ENTITY_ANNUAL,
346
+ "cost_connection_annualized_d_ec": COST_ENTITY_ANNUAL,
347
+ "cost_node_annualized_d_ec": COST_ENTITY_ANNUAL,
348
+ "cost_unit_discounted_d_ec": COST_ENTITY_DISCOUNTED,
349
+ "cost_connection_discounted_d_ec": COST_ENTITY_DISCOUNTED,
350
+ "cost_node_discounted_d_ec": COST_ENTITY_DISCOUNTED,
351
+ # Unit / connection flows
352
+ "unit_outputNode_dt_ee": RATE,
353
+ "unit_inputNode_dt_ee": RATE,
354
+ "unit_outputNode_d_ee": ENERGY_ANNUAL,
355
+ "unit_inputNode_d_ee": ENERGY_ANNUAL,
356
+ "connection_dt_eee": RATE,
357
+ "connection_d_eee": ENERGY_ANNUAL,
358
+ "connection_leftward_dt_eee": RATE,
359
+ "connection_rightward_dt_eee": RATE,
360
+ "connection_losses_dt_eee": RATE,
361
+ "connection_leftward_d_eee": ENERGY_ANNUAL,
362
+ "connection_rightward_d_eee": ENERGY_ANNUAL,
363
+ "connection_losses_d_eee": ENERGY_ANNUAL,
364
+ # CO2
365
+ "CO2_d_g": EMISSION_ANNUAL,
366
+ "process_co2_d_eee": EMISSION_ANNUAL,
367
+ "CO2__": EMISSION_MT,
368
+ # Node / group flows
369
+ "nodeGroup_flows_d_g": PREENERGY_ANNUAL,
370
+ "nodeGroup_flows_d_gpe": PREENERGY_ANNUAL,
371
+ # dt group/node flows show the un-integrated MW level (no step_duration —
372
+ # relatable to installed capacity), per maintainer convention. Only
373
+ # sum_flow_t (gpe) integrates the flow columns to MWh/step.
374
+ "nodeGroup_flows_dt_g": RATE,
375
+ "nodeGroup_flows_dt_gpe": ENERGY_PERSTEP,
376
+ "node_inflow__dt": RATE,
377
+ "node_state_dt_e": STORAGE_STATE,
378
+ # VRE shares + curtailment/potential. NOTE: the *_d_ee curtailment and
379
+ # potential are SUM-ONLY over the sample (per_period), NOT annualized —
380
+ # unlike unit_outputNode_d_ee (see Task #4 / Stage-1 finding).
381
+ "nodeGroup_VRE_share_d_g": RATIO,
382
+ "nodeGroup_VRE_share_dt_g": RATIO,
383
+ "unit_VRE_potential_outputNode_dt_ee": RATE,
384
+ "unit_curtailment_outputNode_dt_ee": RATE,
385
+ "unit_VRE_potential_outputNode_d_ee": ENERGY_PERIOD,
386
+ "unit_curtailment_outputNode_d_ee": ENERGY_PERIOD,
387
+ "unit_curtailment_share_outputNode_dt_ee": RATIO,
388
+ "unit_curtailment_share_outputNode_d_ee": RATIO,
389
+ # Ramps
390
+ "unit_ramp_inputs_dt_ee": RAMP,
391
+ "unit_ramp_outputs_dt_ee": RAMP,
392
+ # Node slacks (dt = MW at the timestep; d = annualized MWh/a — out_node:120-126)
393
+ "node_slack_up_dt_e": RATE,
394
+ "node_slack_down_dt_e": RATE,
395
+ "node_slack_up_d_e": ENERGY_ANNUAL,
396
+ "node_slack_down_d_e": ENERGY_ANNUAL,
397
+ "nodeGroup_slack_nonsync_dt_g": RATE,
398
+ "nodeGroup_slack_nonsync_d_g": ENERGY_ANNUAL,
399
+ # Capacity (a stock, not a flow)
400
+ "unit_capacity_ed_p": CAPACITY_MW,
401
+ "connection_capacity_ed_p": CAPACITY_MW,
402
+ "node_capacity_ed_p": CAPACITY_MWH,
403
+ # Costs / factors
404
+ "costs_discounted_p_": MONEY_DISCOUNTED,
405
+ "discountFactors_d_p": FACTOR,
406
+ "entity_annuity_d_p": ANNUITY,
407
+ "years_represented__d": YEARS,
408
+ # Start-ups
409
+ "unit_startup_d_e": COUNT_ANNUAL,
410
+ # Prices / duals
411
+ "node_prices_dt_e": PRICE_ENERGY,
412
+ "reserve_prices_dt_ppg": PRICE_RESERVE,
413
+ "co2_price_period_d_g": PRICE_CO2,
414
+ "co2_price_total_d_g": PRICE_CO2,
415
+ "dual_invest_effective_unit_d_e": DUAL_INVEST_KW,
416
+ "dual_invest_effective_connection_d_e": DUAL_INVEST_KW,
417
+ "dual_invest_effective_node_d_e": DUAL_INVEST_KWH,
418
+ # Inertia (MW·s) + reserve provision + online counts
419
+ "nodeGroup_inertia_dt_g": INERTIA,
420
+ "nodeGroup_unit_node_inertia_dt_gee": INERTIA,
421
+ "nodeGroup_inertia_largest_flow_dt_g": RATE,
422
+ "nodeGroup_slack_inertia_dt_g": INERTIA,
423
+ "nodeGroup_slack_inertia_d_g": INERTIA_ANNUAL,
424
+ "process_reserve_upDown_node_dt_eppe": RATE,
425
+ "process_reserve_average_d_eppe": RESERVE_AVG,
426
+ "dc_angle_dt_e": ANGLE,
427
+ "dc_angle_diff_dt_e": ANGLE,
428
+ "unit_online_dt_e": ONLINE_COUNT,
429
+ "unit_online_average_d_e": ONLINE_AVG,
430
+ "flowGroup_gd_t": RATE,
431
+ "nodeGroup_slack_reserve_dt_eeg": RATE,
432
+ "nodeGroup_slack_reserve_d_eeg": ENERGY_ANNUAL,
433
+ "nodeGroup_slack_capacity_margin_d_g": SLACK_CAP_MARGIN,
434
+ "nodeGroup_total_inflow": PREENERGY_ANNUAL,
435
+ # Node balance tables. _dt_ep is the un-integrated MW level (assembled
436
+ # from raw flow_dt / inflow / slacks with no step_duration, like the dt
437
+ # group flows); _d_ep integrates + annualizes to MWh/a.
438
+ "node_dt_ep": RATE,
439
+ "node_d_ep": ENERGY_ANNUAL,
440
+ # Mixed-unit tables — per-column transform maps.
441
+ "nodeGroup_gdt_p": _NODEGROUP_GDT_P,
442
+ "nodeGroup_gd_p": _NODEGROUP_GD_P,
443
+ "flowGroup_gd_p": _FLOWGROUP_GD_P,
444
+ # Pure membership / index sets (no measure columns)
445
+ "group_node": DIMENSION_TABLE,
446
+ "group_process": DIMENSION_TABLE,
447
+ "group_process_node": DIMENSION_TABLE,
448
+ "nodeGroupDispatch": DIMENSION_TABLE,
449
+ "nodeGroupIndicators": DIMENSION_TABLE,
450
+ "flowGroupIndicators": DIMENSION_TABLE,
451
+ "connection_dc_power_flow": DIMENSION_TABLE,
452
+ "node_dc_power_flow": DIMENSION_TABLE,
453
+ }
454
+
455
+ # Per-(output, column) formula overrides — only where a bespoke derivation
456
+ # string helps more than the transform's generic tooltip.
457
+ FORMULA_OVERRIDE: dict[tuple[str, str], str] = {
458
+ ("annualized_costs_d_p", "commodity_cost"):
459
+ "Σₜ(costs__dt.commodity_cost) / complete_period_share_of_year / 1e6",
460
+ }
461
+
462
+ DOCS_ANCHOR: dict[str, str] = {
463
+ "annualized_costs_d_p": "results.html#costs",
464
+ "costs_dt_p": "results.html#costs",
465
+ "cost_unit_annualized_d_ec": "results.html#cost-by-entity",
466
+ "cost_connection_annualized_d_ec": "results.html#cost-by-entity",
467
+ "cost_node_annualized_d_ec": "results.html#cost-by-entity",
468
+ "cost_unit_discounted_d_ec": "results.html#cost-by-entity",
469
+ "cost_connection_discounted_d_ec": "results.html#cost-by-entity",
470
+ "cost_node_discounted_d_ec": "results.html#cost-by-entity",
471
+ }
472
+
473
+
474
+ def is_dimension(column: str) -> bool:
475
+ """True if ``column`` is an index/key dimension rather than a measure."""
476
+ return column in DIMENSION_COLUMNS
477
+
478
+
479
+ def result_key_summary(result_key: str) -> "tuple[str, str, str] | None":
480
+ """Output-level ``(unit, semantics, tooltip)`` for a result key.
481
+
482
+ For a uniform output this is its transform's fields; for a mixed-unit
483
+ table the unit lists the distinct measure units and semantics is
484
+ ``"mixed"``. Returns ``None`` for an undeclared output or a pure
485
+ membership/index set (nothing to annotate). Intended for UI hovers
486
+ (e.g. the result viewer's plot-tree tooltip), where ``result_key`` is the
487
+ plot variant's key.
488
+ """
489
+ spec = OUTPUT_TRANSFORM.get(result_key)
490
+ if spec is None:
491
+ return None
492
+ if isinstance(spec, dict):
493
+ units = sorted({t.unit for t in spec.values() if t.unit})
494
+ sems = sorted({t.semantics.value for t in spec.values()
495
+ if t.semantics is not Semantics.DIMENSION})
496
+ if not sems:
497
+ return None
498
+ return ("; ".join(units) if units else "ratio",
499
+ "mixed" if len(sems) > 1 else sems[0],
500
+ "Mixed-unit table — units vary by column.")
501
+ if spec.semantics is Semantics.DIMENSION:
502
+ return None
503
+ return (spec.unit or "ratio", spec.semantics.value, spec.tooltip)
504
+
505
+
506
+ def result_variant_summary(
507
+ result_key: str, variant_letter: str
508
+ ) -> "tuple[str, str, str] | None":
509
+ """Variant-adjusted ``(unit, semantics, tooltip)`` for a plot variant.
510
+
511
+ The ``a`` (sum-over-periods → total) and ``w`` (weekly chunk
512
+ aggregation) plot variants are *on-the-fly* aggregations layered on the
513
+ same base ``result_key`` as the ``p``/``h`` variants — they have no
514
+ output of their own in :data:`OUTPUT_TRANSFORM`. So their unit and
515
+ semantics differ from the base summary and must be derived here:
516
+
517
+ * ``'a'`` — the variant totals the base over the whole horizon. An
518
+ annual-rate base unit (suffix ``/a``, e.g. ``M CUR/a``, ``MWh/a``)
519
+ becomes the absolute total (``M CUR``, ``MWh``); a base unit without
520
+ ``/a`` (e.g. a discounted ``M CUR``) is already absolute and stays as
521
+ is. Semantics become ``"total"`` (a horizon total, *not* annual).
522
+ * ``'w'`` — weekly chunk aggregation keeps the base/``h`` unit unchanged;
523
+ semantics become ``"weekly"``.
524
+ * any other letter (``'p'``, ``'h'``, …) — the base summary unchanged.
525
+
526
+ Returns ``None`` when the base output is undeclared or a pure
527
+ membership/index set (i.e. when :func:`result_key_summary` returns
528
+ ``None``), so callers can skip it exactly as they do for the base.
529
+ Pure and GUI-free; the single source for variant-aware UI hovers and
530
+ plot value-axis units.
531
+ """
532
+ base = result_key_summary(result_key)
533
+ if base is None:
534
+ return None
535
+ unit, semantics, desc = base
536
+ if variant_letter == 'a':
537
+ if unit and unit.endswith('/a'):
538
+ unit = unit[:-2]
539
+ return (unit, "total", desc)
540
+ if variant_letter == 'w':
541
+ return (unit, "weekly", desc)
542
+ return base
543
+
544
+
545
+ def output_metadata_rows(output_key: str, columns) -> list[dict[str, str]]:
546
+ """Flat ``[{output, column, unit, semantics, tooltip, formula}, …]`` rows
547
+ for the *measure* columns of one output (dimension columns dropped).
548
+
549
+ Single source for the tabular renderers that list metadata in a side
550
+ table (the Excel ``_output_metadata`` sheet). Returns ``[]`` for an
551
+ undeclared output or a pure membership/index set.
552
+ """
553
+ meta = derive_column_meta(output_key, columns)
554
+ if not meta:
555
+ return []
556
+ rows: list[dict[str, str]] = []
557
+ for col in columns:
558
+ cm = meta.get(str(col))
559
+ if cm is None or cm.semantics is Semantics.DIMENSION:
560
+ continue
561
+ rows.append({
562
+ "output": output_key,
563
+ "column": cm.name,
564
+ "unit": cm.unit,
565
+ "semantics": cm.semantics.value,
566
+ "tooltip": cm.tooltip,
567
+ "long": cm.long,
568
+ "formula": cm.formula,
569
+ })
570
+ return rows
571
+
572
+
573
+ def datapackage_resource(
574
+ output_key: str, path: str, index_names, measure_columns,
575
+ ) -> dict:
576
+ """A Frictionless ``tabular-data-resource`` describing one written CSV.
577
+
578
+ ``index_names`` are the (reset-to-column) dimension levels and
579
+ ``measure_columns`` the value columns — together the CSV's header. Each
580
+ field carries its ``type`` and, for measures, the derived ``unit`` /
581
+ semantics / description / formula (under ``flextool:`` keys so the
582
+ descriptor stays valid Frictionless). Always returns a resource (an
583
+ undeclared output simply yields string/dimension fields), so the sidecar
584
+ documents the full ``output_csv/`` tree, declared or not.
585
+ """
586
+ meta = derive_column_meta(output_key, measure_columns) or {}
587
+ fields: list[dict[str, str]] = []
588
+ seen: set[str] = set()
589
+
590
+ def _dimension_field(name: str) -> dict[str, str]:
591
+ return {"name": name, "type": "string", "flextool:semantics": "dimension"}
592
+
593
+ for name in index_names:
594
+ if name is None:
595
+ continue
596
+ key = str(name)
597
+ if key in seen:
598
+ continue
599
+ seen.add(key)
600
+ fields.append(_dimension_field(key))
601
+
602
+ for col in measure_columns:
603
+ name = str(col)
604
+ if name in seen:
605
+ continue
606
+ seen.add(name)
607
+ cm = meta.get(name)
608
+ if cm is None or cm.semantics is Semantics.DIMENSION:
609
+ fields.append(_dimension_field(name))
610
+ continue
611
+ spec: dict[str, str] = {
612
+ "name": name,
613
+ "type": "number",
614
+ "flextool:semantics": cm.semantics.value,
615
+ }
616
+ if cm.unit:
617
+ spec["unit"] = cm.unit
618
+ # Frictionless `description` carries the longer form when present.
619
+ if cm.long or cm.tooltip:
620
+ spec["description"] = cm.long or cm.tooltip
621
+ if cm.formula:
622
+ spec["flextool:formula"] = cm.formula
623
+ if cm.docs:
624
+ spec["flextool:docs"] = cm.docs
625
+ fields.append(spec)
626
+
627
+ return {
628
+ "name": output_key.replace("_", "-").strip("-").lower() or "resource",
629
+ "path": path,
630
+ "profile": "tabular-data-resource",
631
+ "schema": {"fields": fields},
632
+ }
633
+
634
+
635
+ def derive_column_meta(
636
+ output_key: str, columns,
637
+ ) -> dict[str, ColumnMeta] | None:
638
+ """Derive ``{column_name: ColumnMeta}`` for one output.
639
+
640
+ ``columns`` is the iterable of *leaf* column names (e.g. cost categories),
641
+ typically ``df.columns`` before any scenario-level wrapping. Dimension
642
+ columns get ``Semantics.DIMENSION``; measure columns get the output's
643
+ declared transform. The declared value may be a single ``Transform``
644
+ (uniform) or a ``{column_label: Transform}`` map (mixed-unit tables, with
645
+ an optional ``'*'`` default). Returns ``None`` when the output is not (yet)
646
+ declared in :data:`OUTPUT_TRANSFORM`, so callers can treat absence as
647
+ "no metadata".
648
+ """
649
+ spec = OUTPUT_TRANSFORM.get(output_key)
650
+ if spec is None:
651
+ return None
652
+ docs = DOCS_ANCHOR.get(output_key, "")
653
+ out: dict[str, ColumnMeta] = {}
654
+ for col in columns:
655
+ name = str(col)
656
+ tf = spec.get(name, spec.get('*')) if isinstance(spec, dict) else spec
657
+ # Index/key dimension, a pure membership set, or a column absent from a
658
+ # mixed-table map → dimension.
659
+ if tf is None or tf.semantics is Semantics.DIMENSION or is_dimension(name):
660
+ out[name] = ColumnMeta(name, "", Semantics.DIMENSION,
661
+ "Index / key dimension.")
662
+ continue
663
+ out[name] = ColumnMeta(
664
+ name=name,
665
+ unit=tf.unit,
666
+ semantics=tf.semantics,
667
+ tooltip=tf.tooltip,
668
+ long=tf.description,
669
+ formula=FORMULA_OVERRIDE.get((output_key, name), ""),
670
+ docs=docs,
671
+ )
672
+ return out