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,244 @@
1
+ """Comparison-mode helpers built on the merged ``default_plots.yaml``.
2
+
3
+ Each leaf config in ``default_plots.yaml`` carries optional comparison-mode
4
+ add-ons:
5
+
6
+ * ``scenario_rule`` — single character that says how the ``scenario`` dim is
7
+ folded into the comparison view (``g``=grouped/coloured bars, ``l``=lines,
8
+ ``u``=subplots, ``f``=files, ``s``=stacked, ``e``=expand-axis).
9
+ * ``comparison_overrides`` — optional dict of plot-setting overrides applied
10
+ on top of the single config in comparison mode. When it includes
11
+ ``map_dimensions_for_plots``, that explicit value wins over the value
12
+ auto-derived from ``single + scenario_rule``.
13
+
14
+ Two helpers live here:
15
+
16
+ * :func:`derive_comparison_config` — builds a comparison-mode ``PlotConfig``
17
+ from a single-mode one by inserting ``s`` + ``scenario_rule`` into the
18
+ column part of ``index_types``/``rules`` and merging in
19
+ ``comparison_overrides``.
20
+ * :func:`union_raw_data` — reads each viewer scenario's raw result parquet
21
+ and unions them with ``scenario`` as the outermost column-MultiIndex
22
+ level, so the derived config can be applied via the standard
23
+ ``compute_live_plan`` pipeline.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import logging
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import pandas as pd
33
+
34
+ try:
35
+ from flextool.lean_parquet import read_lean_parquet
36
+ except ImportError: # pragma: no cover — defensive fallback
37
+ read_lean_parquet = None # type: ignore[assignment]
38
+
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def _read_parquet(path: Path) -> pd.DataFrame:
44
+ """Read a parquet file, preferring the lean reader when available."""
45
+ if read_lean_parquet is not None:
46
+ return read_lean_parquet(path)
47
+ return pd.read_parquet(path)
48
+
49
+
50
+ def _cfg_get(cfg: Any, name: str) -> Any:
51
+ """Read *name* from a config that may be a PlotConfig dataclass or dict."""
52
+ if hasattr(cfg, name):
53
+ return getattr(cfg, name)
54
+ if isinstance(cfg, dict):
55
+ return cfg.get(name)
56
+ return None
57
+
58
+
59
+ def has_comparison_view(cfg: Any) -> bool:
60
+ """Return ``True`` if *cfg* is renderable in comparison mode.
61
+
62
+ A config has a comparison view when it sets ``scenario_rule`` (the
63
+ ``scenario`` dim is auto-folded into the rules) **or** when its
64
+ ``comparison_overrides`` supply ``map_dimensions_for_plots`` (the
65
+ comparison layout is given explicitly, so ``scenario_rule`` is
66
+ irrelevant). Mirrors the gate in :func:`derive_comparison_config`.
67
+ """
68
+ if _cfg_get(cfg, "scenario_rule") is not None:
69
+ return True
70
+ overrides = _cfg_get(cfg, "comparison_overrides")
71
+ if isinstance(overrides, dict):
72
+ md = overrides.get("map_dimensions_for_plots")
73
+ if isinstance(md, (list, tuple)) and len(md) >= 2:
74
+ return True
75
+ return False
76
+
77
+
78
+ def derive_comparison_config(single_cfg: Any) -> Any:
79
+ """Build a comparison-mode PlotConfig from a single-mode one.
80
+
81
+ The unioned **raw** comparison frame has shape::
82
+
83
+ row = (single's row dims)
84
+ col = ('scenario', *single's col dims)
85
+
86
+ so we prepend ``s`` to ``index_types``'s column part and prepend
87
+ ``scenario_rule`` to the rules string's column part. The single-mode
88
+ rules string is otherwise unchanged, which means
89
+ ``_apply_dimension_rules`` will reshape the data exactly as it does in
90
+ single mode, with one extra column dim representing scenario.
91
+
92
+ ``comparison_overrides`` (also on the single config) provides per-mode
93
+ overrides for layout knobs. Overrides win — including
94
+ ``map_dimensions_for_plots`` if explicitly given (used by the rare
95
+ configs whose comparison view wants a different visual treatment than
96
+ "single + scenario_rule prepended").
97
+
98
+ ``scenario_rule`` is normally required to mark a config as
99
+ comparison-renderable. It may be omitted, however, when
100
+ ``comparison_overrides`` supplies ``map_dimensions_for_plots`` — that
101
+ fully specifies the comparison layout (including the scenario dim), so
102
+ no auto-derivation is needed and ``scenario_rule`` is irrelevant.
103
+
104
+ Raises ``ValueError`` when neither ``scenario_rule`` nor an override
105
+ ``map_dimensions_for_plots`` is set, or when ``map_dimensions_for_plots``
106
+ is malformed.
107
+ """
108
+ # Read overrides first: a comparison config may give
109
+ # ``map_dimensions_for_plots`` directly here, which fully defines the
110
+ # comparison layout and makes ``scenario_rule`` unnecessary.
111
+ overrides = getattr(single_cfg, "comparison_overrides", None)
112
+ if overrides is None and isinstance(single_cfg, dict):
113
+ overrides = single_cfg.get("comparison_overrides")
114
+ overrides = dict(overrides or {})
115
+ override_md = overrides.get("map_dimensions_for_plots")
116
+ has_override_md = isinstance(override_md, (list, tuple)) and len(override_md) >= 2
117
+
118
+ scenario_rule = getattr(single_cfg, "scenario_rule", None)
119
+ if scenario_rule is None and isinstance(single_cfg, dict):
120
+ scenario_rule = single_cfg.get("scenario_rule")
121
+ if scenario_rule is None and not has_override_md:
122
+ raise ValueError(
123
+ "derive_comparison_config: the single config has no "
124
+ "scenario_rule — define one (e.g. 'g' for grouped bars, "
125
+ "'l' for lines, 'u' for subplots), or supply "
126
+ "map_dimensions_for_plots in comparison_overrides, to enable "
127
+ "comparison view."
128
+ )
129
+
130
+ if scenario_rule is None:
131
+ # ``comparison_overrides`` fully specifies the comparison layout via
132
+ # ``map_dimensions_for_plots``; skip auto-derivation and apply the
133
+ # override as-is below.
134
+ new_md = None
135
+ else:
136
+ if hasattr(single_cfg, "map_dimensions_for_plots"):
137
+ md = single_cfg.map_dimensions_for_plots
138
+ else:
139
+ md = single_cfg.get("map_dimensions_for_plots")
140
+ if not isinstance(md, (list, tuple)) or len(md) < 2:
141
+ raise ValueError(
142
+ f"derive_comparison_config: map_dimensions_for_plots must be a "
143
+ f"2-element [index_types, rules] list, got {md!r}"
144
+ )
145
+ idx, rules = md[0], md[1]
146
+ if not isinstance(idx, str) or "_" not in idx:
147
+ raise ValueError(
148
+ f"derive_comparison_config: index_types must contain '_' "
149
+ f"separating row and column parts, got {idx!r}"
150
+ )
151
+ if not isinstance(rules, str) or "_" not in rules:
152
+ raise ValueError(
153
+ f"derive_comparison_config: rules must contain '_' separating "
154
+ f"row and column parts, got {rules!r}"
155
+ )
156
+ row_idx, col_idx = idx.split("_", 1)
157
+ row_rules, col_rules = rules.split("_", 1)
158
+
159
+ # If the single rules already name 'scenario' in the col part (e.g.
160
+ # ``[d_s, s_b]`` for a costs-by-scenario chart that's the same shape
161
+ # in both modes), don't auto-prepend another ``s`` — the existing rule
162
+ # for scenario stands. ``scenario_rule`` is required to mark the
163
+ # config as comparison-renderable, but its value is unused on this
164
+ # branch (and may be overridden via ``comparison_overrides``).
165
+ if "s" in col_idx:
166
+ new_idx, new_rules = idx, rules
167
+ else:
168
+ new_idx = f"{row_idx}_s{col_idx}"
169
+ new_rules = f"{row_rules}_{scenario_rule}{col_rules}"
170
+ new_md = [new_idx, new_rules]
171
+
172
+ if hasattr(single_cfg, "__dataclass_fields__"):
173
+ valid_fields = set(single_cfg.__dataclass_fields__.keys())
174
+ replace_kwargs = {k: v for k, v in overrides.items() if k in valid_fields}
175
+ # Two-step replace: derive first, then apply overrides — so a config
176
+ # whose comparison rules differ beyond just adding scenario can carry
177
+ # ``map_dimensions_for_plots`` in ``comparison_overrides`` and have it
178
+ # take precedence over the derived value.
179
+ derived = (
180
+ single_cfg if new_md is None
181
+ else dataclasses.replace(single_cfg, map_dimensions_for_plots=new_md)
182
+ )
183
+ return dataclasses.replace(derived, **replace_kwargs)
184
+
185
+ if isinstance(single_cfg, dict):
186
+ new_cfg = dict(single_cfg)
187
+ if new_md is not None:
188
+ new_cfg["map_dimensions_for_plots"] = new_md
189
+ new_cfg.update(overrides) # overrides win, including map_dimensions_for_plots
190
+ return new_cfg
191
+
192
+ raise TypeError(
193
+ f"derive_comparison_config: unsupported config type {type(single_cfg)!r}"
194
+ )
195
+
196
+
197
+ def union_raw_data(
198
+ project_path: Path,
199
+ scenarios: list[str],
200
+ result_key: str,
201
+ ) -> pd.DataFrame | None:
202
+ """Read each scenario's raw result parquet and union them.
203
+
204
+ Each per-scenario raw parquet at ``output_parquet/<scenario>/<rk>.parquet``
205
+ already carries a ``scenario`` column-MultiIndex level (added at write
206
+ time); we drop that local level and re-concat across scenarios with the
207
+ **viewer-supplied** scenario name as the new outermost ``scenario``
208
+ level. This handles the case where a scenario folder name differs from
209
+ the embedded scenario name (e.g. sensitivity replicas where the folder
210
+ is ``scenario_test_6h_2`` but the embedded name is ``scenario_test_6h``).
211
+
212
+ Returns ``None`` when no raw parquet exists for any of *scenarios*.
213
+ Missing files for a subset are silently skipped — the union proceeds
214
+ with whatever is present.
215
+ """
216
+ pieces: list[pd.DataFrame] = []
217
+ found: list[str] = []
218
+ for s in scenarios:
219
+ path = Path(project_path) / "output_parquet" / s / f"{result_key}.parquet"
220
+ if not path.is_file():
221
+ continue
222
+ try:
223
+ df = _read_parquet(path)
224
+ except Exception as exc: # noqa: BLE001
225
+ logger.warning("union_raw_data: failed to read %s: %s", path, exc)
226
+ continue
227
+ # Strip the embedded scenario level so the keys=... we set below is
228
+ # the only authoritative scenario label. Two cases:
229
+ # * MultiIndex columns with a 'scenario' level → droplevel.
230
+ # * Single-level Index named 'scenario' (e.g. costs_discounted_p_,
231
+ # a category × scenario table) → squeeze to a Series so concat
232
+ # below adds 'scenario' as the only column level (otherwise the
233
+ # inner unnamed level lingers and breaks rules like ``s_b``).
234
+ if isinstance(df.columns, pd.MultiIndex) and "scenario" in df.columns.names:
235
+ df = df.droplevel("scenario", axis=1)
236
+ elif (not isinstance(df.columns, pd.MultiIndex)
237
+ and df.columns.name == "scenario"
238
+ and len(df.columns) == 1):
239
+ df = df.iloc[:, 0] # Series with the row index preserved
240
+ pieces.append(df)
241
+ found.append(s)
242
+ if not pieces:
243
+ return None
244
+ return pd.concat(pieces, axis=1, keys=found, names=["scenario"])
@@ -0,0 +1,205 @@
1
+ """Structured seeding of discovered colors into ``plot_settings.yaml``.
2
+
3
+ The GUI color/order picker (Stage 6) is now the canonical editor for the
4
+ project ``plot_settings.yaml``, so the file is treated as a plain STRUCTURED
5
+ data file: load with :mod:`pyyaml`, modify the dict, dump with
6
+ ``sort_keys=False`` to preserve key order (= stacking order). Comments are no
7
+ longer preserved in the PROJECT file — they live only in the bundled template
8
+ (schema reference) and as future UI tooltips/labels.
9
+
10
+ This module provides the single read/modify/write helper used by both seeds:
11
+
12
+ * the upfront **input-DB** seed
13
+ (:mod:`flextool.scenario_comparison.input_entity_colors`), which knows the
14
+ FULL per-class entity set from the input DB and therefore ADDS new names and
15
+ PRUNES stale ones; and
16
+ * the **dispatch** seed
17
+ (:func:`flextool.scenario_comparison.orchestrator._seed_dispatch_colors_into_plot_settings`),
18
+ which discovers only a SUBSET of entities and therefore ADDS only (never
19
+ prunes).
20
+
21
+ Add semantics (always): for each entity class / scenario, names not already
22
+ present (case-insensitive) are appended AFTER the existing entries in that
23
+ subsection. Existing entry ORDER and existing VALUES are never disturbed (a
24
+ user's hand-picked color or order is preserved).
25
+
26
+ Prune semantics (opt-in, ``prune_entities``): names whose lowercased form is
27
+ NOT in the supplied full set for that class are removed from ``entities.<class>``.
28
+ Only ``entities.*`` is ever pruned; ``categories.*`` (schema-fixed) and
29
+ ``scenarios`` are never pruned.
30
+
31
+ A pass that adds nothing and prunes nothing makes no write (returns ``False``),
32
+ so a freshly-copied, still-commented project file keeps its comments until the
33
+ first real change.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from pathlib import Path
39
+
40
+ import yaml
41
+
42
+ # Entity classes managed in the ``entities`` section (the group class is split
43
+ # into nodeGroup/flowGroup by membership upstream). Legacy ``group`` is NOT
44
+ # listed — old projects keep an inert ``entities.group`` section, never
45
+ # written or pruned here.
46
+ _ENTITY_CLASSES = ("nodeGroup", "flowGroup", "unit", "connection", "node")
47
+
48
+
49
+ def dump_plot_settings(data: dict) -> str:
50
+ """Serialize a ``plot_settings.yaml`` mapping to text.
51
+
52
+ Single source of truth for the dump parameters shared by the seed
53
+ writer here and the GUI color/order picker
54
+ (:mod:`flextool.gui.dialogs.plot_settings_picker`). ``sort_keys=False``
55
+ preserves KEY ORDER (= stacking order); ``default_flow_style=False``
56
+ keeps the block style; ``allow_unicode=True`` round-trips unicode
57
+ entity names verbatim.
58
+ """
59
+ return yaml.safe_dump(
60
+ data,
61
+ sort_keys=False,
62
+ default_flow_style=False,
63
+ allow_unicode=True,
64
+ )
65
+
66
+
67
+ def _load_mapping(path: Path) -> dict:
68
+ """Load *path* as a YAML mapping (``None`` / empty -> ``{}``).
69
+
70
+ Raises :class:`ValueError` on a parse error or a non-mapping top level.
71
+ """
72
+ text = path.read_text(encoding="utf-8")
73
+ try:
74
+ parsed = yaml.safe_load(text)
75
+ except yaml.YAMLError as exc: # pragma: no cover - corrupt file
76
+ raise ValueError(f"Cannot parse {path}: {exc}") from exc
77
+ if parsed is None:
78
+ return {}
79
+ if not isinstance(parsed, dict):
80
+ raise ValueError(f"{path} is not a YAML mapping")
81
+ return parsed
82
+
83
+
84
+ def _existing_lc(section: dict) -> set[str]:
85
+ """Lowercased key set of *section* (for case-insensitive de-dup)."""
86
+ return {str(k).lower() for k in section}
87
+
88
+
89
+ def _add_entries(
90
+ section: dict,
91
+ wanted: dict[str, str],
92
+ ) -> bool:
93
+ """Append names from *wanted* not already in *section* (case-insensitive).
94
+
95
+ Mutates *section* in place; existing entries (order + value) are left
96
+ untouched. Returns ``True`` if anything was added.
97
+ """
98
+ existing = _existing_lc(section)
99
+ added = False
100
+ for name, color in wanted.items():
101
+ if str(name).lower() in existing:
102
+ continue
103
+ section[name] = color
104
+ existing.add(str(name).lower())
105
+ added = True
106
+ return added
107
+
108
+
109
+ def _prune_entries(section: dict, keep_lc: set[str]) -> bool:
110
+ """Remove keys of *section* whose lowercase is NOT in *keep_lc*.
111
+
112
+ Mutates *section* in place. Returns ``True`` if anything was removed.
113
+ """
114
+ stale = [k for k in section if str(k).lower() not in keep_lc]
115
+ for k in stale:
116
+ del section[k]
117
+ return bool(stale)
118
+
119
+
120
+ def seed_colors_into_plot_settings(
121
+ path: Path,
122
+ entity_colors: dict[str, dict[str, str]],
123
+ scenario_colors: dict[str, str],
124
+ prune_entities: dict[str, set[str]] | None = None,
125
+ ) -> bool:
126
+ """Structurally add (and optionally prune) colors in ``plot_settings.yaml``.
127
+
128
+ *entity_colors* maps entity class (``nodeGroup`` / ``flowGroup`` /
129
+ ``unit`` / ``connection`` / ``node``) to an ordered ``{name -> color}``
130
+ mapping (unknown classes are ignored); *scenario_colors* is
131
+ the ``{name -> color}`` mapping for the ``scenarios`` section. Color
132
+ values are written verbatim (``"#RRGGBB"`` strings, matplotlib color
133
+ names, or ``{color, neg_color}`` dicts — whatever the caller supplies).
134
+
135
+ ADD: names not already present (active key, case-insensitively) in their
136
+ target (sub)section are appended AFTER the existing entries. Existing
137
+ entries — their order and their values — are never overwritten.
138
+
139
+ PRUNE (only when *prune_entities* is given): for each class present in
140
+ *prune_entities*, entity names in ``entities.<class>`` whose lowercased
141
+ name is NOT in ``prune_entities[class]`` are removed. This is how the
142
+ input-DB seed drops entities that no longer exist in the DB. Only
143
+ ``entities.*`` is pruned — never ``categories.*`` (schema-fixed) nor
144
+ ``scenarios``. When *prune_entities* is ``None`` the call is add-only.
145
+
146
+ The file is loaded with :func:`yaml.safe_load`, modified, and re-dumped
147
+ with ``sort_keys=False`` so KEY ORDER (= stacking order) is preserved and
148
+ ``"#RRGGBB"`` values round-trip as quoted strings. ``categories.*``,
149
+ ``scenarios``, and any unmanaged keys are preserved.
150
+
151
+ Returns ``True`` when the file was modified, ``False`` when nothing was
152
+ added or pruned (the no-op / idempotent case — the file is left untouched
153
+ on disk so a still-commented fresh copy keeps its comments).
154
+ """
155
+ path = Path(path)
156
+ data = _load_mapping(path)
157
+
158
+ modified = False
159
+
160
+ # --- Entities: add + (optional) prune -----------------------------------
161
+ entities = data.get("entities")
162
+ entities_existed = isinstance(entities, dict)
163
+ if not entities_existed:
164
+ entities = {}
165
+
166
+ for cls in _ENTITY_CLASSES:
167
+ wanted = entity_colors.get(cls) or {}
168
+ do_prune = prune_entities is not None and cls in prune_entities
169
+ if not wanted and not do_prune:
170
+ continue
171
+
172
+ class_map = entities.get(cls)
173
+ if not isinstance(class_map, dict):
174
+ class_map = {}
175
+
176
+ changed_here = False
177
+ if wanted and _add_entries(class_map, wanted):
178
+ changed_here = True
179
+ if do_prune and _prune_entries(class_map, prune_entities[cls]):
180
+ changed_here = True
181
+
182
+ if changed_here:
183
+ # Attach the (possibly newly created) class map to the section.
184
+ entities[cls] = class_map
185
+ modified = True
186
+
187
+ # Only attach the entities section if we actually changed something and
188
+ # there is content to write — never inject an empty section on a no-op.
189
+ if modified and entities and not entities_existed:
190
+ data["entities"] = entities
191
+
192
+ # --- Scenarios: add only -------------------------------------------------
193
+ if scenario_colors:
194
+ scenarios = data.get("scenarios")
195
+ if not isinstance(scenarios, dict):
196
+ scenarios = {}
197
+ if _add_entries(scenarios, scenario_colors):
198
+ data["scenarios"] = scenarios
199
+ modified = True
200
+
201
+ if not modified:
202
+ return False
203
+
204
+ path.write_text(dump_plot_settings(data), encoding="utf-8")
205
+ return True
@@ -0,0 +1,71 @@
1
+ # FlexTool axis contract
2
+
3
+ This directory holds two coupled JSON files that together define the
4
+ canonical FlexTool axis vocabularies for the `pl.Enum` dtype activation
5
+ (see `specs/enum_dtype_refactor_plan.md`):
6
+
7
+ | File | Purpose |
8
+ |---|---|
9
+ | `flextool_axis_contract.json` | Authoritative declaration of every axis the cascade casts to `pl.Enum`. Each axis row names the entity class / parameter map / synthetic-token list that supplies its vocabulary. |
10
+ | `flextool_axis_contract.schema.json` | JSON Schema (draft 2020-12) validating the structure of `flextool_axis_contract.json` itself. |
11
+
12
+ The contract is consumed by `flextool/spinedb_backend/_axis_enums.py`
13
+ (landing in Phase 1) at runtime to build the per-axis `pl.Enum` dtypes
14
+ that the Backend and the cascade cast against.
15
+
16
+ ## Coupling with `spinedb_schema.json`
17
+
18
+ The contract migrates **hand-in-hand** with `spinedb_schema.json`.
19
+ Any schema change that adds, renames, or removes an entity class or a
20
+ parameter definition referenced by an axis row REQUIRES updating both
21
+ files in the same commit:
22
+
23
+ * If you add a new entity class that the cascade joins on, decide
24
+ whether it deserves its own axis and add a row to `axes`.
25
+ * If you rename an entity class, update every `source` field that
26
+ references it.
27
+ * If you remove an entity class, remove or repoint the axis row.
28
+ * If you rename or remove a parameter referenced under
29
+ `source_type: parameter_keys` or `parameter_value_list`, update the
30
+ `source.entity_class` / `source.parameter` / `source.parameters` field.
31
+
32
+ The `_template_master_version_compat` block at the top of
33
+ `flextool_axis_contract.json` pins the schema commit hash the contract
34
+ was last verified against. Bump that block whenever a schema-affecting
35
+ edit lands.
36
+
37
+ ## Self-validation
38
+
39
+ `tests/spinedb_backend/test_axis_contract.py` validates the contract
40
+ against its own schema and cross-checks that every entity class /
41
+ parameter referenced by an axis row exists in `spinedb_schema.json`.
42
+ Run it after every contract edit:
43
+
44
+ ```bash
45
+ python -m pytest tests/spinedb_backend/test_axis_contract.py -v
46
+ ```
47
+
48
+ ## Adding or changing an axis
49
+
50
+ 1. Decide the `source_type`:
51
+ - `entity_class` — one entity class, single member list.
52
+ - `entity_class_union` — multiple entity classes unioned (e.g. process = unit ∪ connection).
53
+ - `parameter_keys` — vocabulary lives in the keys of a parameter Map (or the values of an Array).
54
+ - `parameter_value_list` — vocabulary is the values of a Spine `parameter_value_list`.
55
+ - `synthetic` — fixed tokens introduced by cascade derivations (no DB source).
56
+ - `union` — composite axis combining other axes' vocabularies.
57
+ 2. Fill in the `source` field — string / list / object per `source_type`.
58
+ 3. Add `column_synonyms` if the cascade uses friendly column names that should map to the same enum (mirrors `_AXIS_SYNONYMS` in `flextool/engine_polars/_axis_enums.py`).
59
+ 4. Add `filter` if the vocabulary is intersected with another set (e.g. scenario filter, realized periods).
60
+ 5. If introducing synthetic tokens that aren't from the DB, register them in `synthetic_token_allowlist` AND in the axis's `tokens` field (for `source_type: synthetic`) or `tokens_default_extension` (for hybrid axes).
61
+ 6. Update the schema (`flextool_axis_contract.schema.json`) only if you need a new `source_type` enum value or a new optional field shape.
62
+ 7. Run the self-check tests.
63
+
64
+ ## Outstanding review notes
65
+
66
+ The `_review_notes` array at the bottom of `flextool_axis_contract.json`
67
+ tracks Phase 0 audit findings that need Phase 1 resolution decisions
68
+ (e.g. column-letter collisions between axes like `c` for both commodity
69
+ and constraint, or `b` for both block and branch). These notes are
70
+ informational; Phase 1's `build_axis_enums` is where the disambiguation
71
+ strategy is actually implemented.