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,272 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from flextool.plot_outputs.config import _is_new_format_entry
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ GROUP_NAMES: dict[str, str] = {
14
+ "0": "Infeasibilities",
15
+ "1": "Curtailments",
16
+ "2": "Flows",
17
+ "3": "Costs",
18
+ "4": "Capacities",
19
+ "5": "Emissions",
20
+ "6": "NodeGroups",
21
+ "7": "Nodes",
22
+ "8": "Unit/Conn details",
23
+ "9": "Extras",
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class PlotVariant:
29
+ """A single variant (letter) within a plot entry."""
30
+
31
+ letter: str # e.g., "d", "t", "g", "a", "tf"
32
+ result_key: str # e.g., "node_slack_up_dt_e"
33
+ sub_config: str # e.g., "default", "chunks"
34
+ full_name: str # e.g., "Loss of load (upward slack)"
35
+
36
+
37
+ @dataclass
38
+ class PlotEntry:
39
+ """A second-level entry in the plot tree (e.g., "0.0")."""
40
+
41
+ number: str # e.g., "0.0"
42
+ short_name: str # e.g., "Loss of load (upwar..."
43
+ full_name: str # e.g., "Loss of load (upward slack)"
44
+ variants: list[PlotVariant] = field(default_factory=list)
45
+
46
+
47
+ @dataclass
48
+ class PlotGroup:
49
+ """A first-level group in the plot tree (e.g., "0")."""
50
+
51
+ number: str # e.g., "0"
52
+ name: str # e.g., "Infeasibilities"
53
+ entries: list[PlotEntry] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class PlotConfigData:
58
+ """Parsed plot config: tree groups plus top-level metadata.
59
+
60
+ Attributes
61
+ ----------
62
+ groups:
63
+ Plot tree as a list of :class:`PlotGroup` objects.
64
+ default_durations:
65
+ Map of variant letter -> default time-range duration. Values are
66
+ either an ``int`` (number of timesteps) or the string ``"all"``
67
+ (resolved at view time to the available data length). Empty if
68
+ the YAML does not define a ``default_duration`` key.
69
+ """
70
+
71
+ groups: list[PlotGroup] = field(default_factory=list)
72
+ default_durations: dict[str, int | str] = field(default_factory=dict)
73
+
74
+ # Allow legacy callers that treat the result as a list of groups
75
+ # (iteration, len, indexing, equality with []) to keep working.
76
+ def __iter__(self):
77
+ return iter(self.groups)
78
+
79
+ def __len__(self) -> int:
80
+ return len(self.groups)
81
+
82
+ def __getitem__(self, index):
83
+ return self.groups[index]
84
+
85
+ def __eq__(self, other: object) -> bool:
86
+ if isinstance(other, PlotConfigData):
87
+ return (
88
+ self.groups == other.groups
89
+ and self.default_durations == other.default_durations
90
+ )
91
+ if isinstance(other, list):
92
+ return self.groups == other
93
+ return NotImplemented
94
+
95
+ def __bool__(self) -> bool:
96
+ return bool(self.groups) or bool(self.default_durations)
97
+
98
+
99
+
100
+ def _derive_variant_letter(result_key: str, sub_config: str,
101
+ sub_value: dict | None = None) -> str:
102
+ """Derive variant letter from result_key suffix and sub_config name.
103
+
104
+ The variant letter determines the display order and label for each
105
+ plot variant (p=period, h=hourly, w=weekly, a=aggregated).
106
+
107
+ If *sub_value* contains an explicit ``variant`` key, that value is
108
+ used directly.
109
+ """
110
+ # Explicit override
111
+ if sub_value and 'variant' in sub_value:
112
+ return str(sub_value['variant'])
113
+ if sub_config == 'chunks':
114
+ return 'w'
115
+ if sub_config == 'sum_periods':
116
+ return 'a'
117
+ if sub_config == 'lines':
118
+ return 'h' # lines are always time-based
119
+ # Default: derive from result_key data type
120
+ is_dt = '_dt_' in result_key or result_key.endswith('_dt') or '_gdt_' in result_key
121
+ if is_dt:
122
+ return 'h'
123
+ return 'p'
124
+
125
+
126
+ def _extract_plot_items(
127
+ plots: dict,
128
+ ) -> list[tuple[str, str, str, str, str, str]]:
129
+ """Walk the YAML *plots* dict and yield parsed plot items.
130
+
131
+ Each entry under ``plots:`` is an entry-name key with ``group``/``order``
132
+ fields and nested result_keys.
133
+
134
+ Each item is ``(group, number, variant, human_name, result_key, sub_config)``.
135
+ Entries without ``map_dimensions_for_plots`` are skipped.
136
+ """
137
+ items: list[tuple[str, str, str, str, str, str]] = []
138
+ for key, value in plots.items():
139
+ if not isinstance(value, dict):
140
+ continue
141
+ if not _is_new_format_entry(value):
142
+ continue
143
+
144
+ entry_name = key
145
+ group_num = str(value['group'])
146
+ order_num = str(value['order'])
147
+ number = f"{group_num}.{order_num}"
148
+
149
+ for result_key, rk_value in value.items():
150
+ if result_key in ('group', 'order') or not isinstance(rk_value, dict):
151
+ continue
152
+ for sub_config, sub_value in rk_value.items():
153
+ if not isinstance(sub_value, dict):
154
+ continue
155
+ if 'map_dimensions_for_plots' not in sub_value:
156
+ continue
157
+ variant_letter = _derive_variant_letter(
158
+ result_key, sub_config, sub_value,
159
+ )
160
+ # Use per-variant plot_name if specified, otherwise entry name
161
+ variant_name = sub_value.get('plot_name', entry_name)
162
+ items.append(
163
+ (group_num, number, variant_letter, variant_name,
164
+ result_key, sub_config)
165
+ )
166
+
167
+ return items
168
+
169
+
170
+ def _parse_default_durations(data: dict) -> dict[str, int | str]:
171
+ """Extract ``default_duration`` mapping from a parsed YAML doc.
172
+
173
+ Values are either an ``int`` (number of timesteps) or the literal
174
+ string ``"all"`` (resolve at view time to the available data length).
175
+ Anything else is silently dropped.
176
+ """
177
+ raw = data.get("default_duration")
178
+ if not isinstance(raw, dict):
179
+ return {}
180
+ cleaned: dict[str, int | str] = {}
181
+ for letter, value in raw.items():
182
+ key = str(letter)
183
+ if isinstance(value, bool):
184
+ continue # reject bools (int subclass)
185
+ if isinstance(value, (int, float)):
186
+ cleaned[key] = int(value)
187
+ elif isinstance(value, str) and value.strip().lower() == "all":
188
+ cleaned[key] = "all"
189
+ return cleaned
190
+
191
+
192
+ def parse_plot_config(config_path: Path) -> PlotConfigData:
193
+ """Parse a plot config YAML and return the tree structure plus defaults.
194
+
195
+ Returns a :class:`PlotConfigData` with two attributes:
196
+
197
+ * ``groups`` — list of :class:`PlotGroup` objects sorted by group number.
198
+ Each group contains :class:`PlotEntry` objects sorted by entry number,
199
+ and each entry contains :class:`PlotVariant` objects for available
200
+ variant letters. Entries without ``map_dimensions_for_plots`` are
201
+ excluded.
202
+ * ``default_durations`` — mapping of variant letter to default time-range
203
+ duration (``int`` or the sentinel string ``"all"``).
204
+
205
+ The returned object is iterable / indexable / comparable to a list of
206
+ :class:`PlotGroup`, so legacy callers keep working.
207
+ """
208
+ if not config_path.is_file():
209
+ logger.warning("Plot config file not found: %s", config_path)
210
+ return PlotConfigData()
211
+
212
+ try:
213
+ with open(config_path, "r", encoding="utf-8") as f:
214
+ data = yaml.safe_load(f)
215
+ except (yaml.YAMLError, OSError) as exc:
216
+ logger.error("Failed to read plot config %s: %s", config_path, exc)
217
+ return PlotConfigData()
218
+
219
+ if not isinstance(data, dict):
220
+ return PlotConfigData()
221
+
222
+ default_durations = _parse_default_durations(data)
223
+
224
+ plots = data.get("plots")
225
+ if not isinstance(plots, dict):
226
+ return PlotConfigData(default_durations=default_durations)
227
+
228
+ items = _extract_plot_items(plots)
229
+
230
+ # Group by entry number, collecting variants
231
+ # Use dict to preserve insertion order; entries keyed by number
232
+ entry_map: dict[str, PlotEntry] = {}
233
+ for group, number, variant, human_name, result_key, sub_config in items:
234
+ if number not in entry_map:
235
+ entry_map[number] = PlotEntry(
236
+ number=number,
237
+ short_name=human_name,
238
+ full_name=human_name,
239
+ variants=[],
240
+ )
241
+ entry = entry_map[number]
242
+ # Update full_name if this variant has a longer/better name
243
+ # (keep the first one encountered for consistency)
244
+ pv = PlotVariant(
245
+ letter=variant,
246
+ result_key=result_key,
247
+ sub_config=sub_config,
248
+ full_name=human_name,
249
+ )
250
+ # Avoid duplicate variant letters for the same entry
251
+ existing_letters = {v.letter for v in entry.variants}
252
+ if variant not in existing_letters:
253
+ entry.variants.append(pv)
254
+
255
+ # Build group structure
256
+ group_map: dict[str, PlotGroup] = {}
257
+ for number, entry in entry_map.items():
258
+ group_num = number.split(".")[0]
259
+ if group_num not in group_map:
260
+ group_map[group_num] = PlotGroup(
261
+ number=group_num,
262
+ name=GROUP_NAMES.get(group_num, f"Group {group_num}"),
263
+ entries=[],
264
+ )
265
+ group_map[group_num].entries.append(entry)
266
+
267
+ # Sort groups by number, entries by number within each group
268
+ groups = sorted(group_map.values(), key=lambda g: int(g.number))
269
+ for group in groups:
270
+ group.entries.sort(key=lambda e: [int(x) for x in e.number.split(".")])
271
+
272
+ return PlotConfigData(groups=groups, default_durations=default_durations)
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ # Subdirectories created inside every new project
7
+ PROJECT_SUBDIRS = [
8
+ "input_sources",
9
+ "converted",
10
+ "intermediate",
11
+ "work",
12
+ "output_plots",
13
+ "output_parquet",
14
+ "output_csv",
15
+ "output_excel",
16
+ "output_plot_comparisons",
17
+ ]
18
+
19
+
20
+ def get_projects_dir() -> Path:
21
+ """Return the path to the top-level ``projects/`` directory.
22
+
23
+ Resolved against the user's current working directory. In an
24
+ editable install with the GUI launched from the repo root this is
25
+ the historical ``<repo>/projects/``; in a wheel install the user is
26
+ expected to launch ``flextool-gui`` from their workspace, where
27
+ ``./projects/`` is created on demand.
28
+ """
29
+ return Path.cwd() / "projects"
30
+
31
+
32
+ def seed_plot_settings(project_path: Path) -> Path:
33
+ """Ensure ``<project_path>/plot_settings.yaml`` exists.
34
+
35
+ Seeds the per-project plot color template from the bundled default so
36
+ the project owns an editable copy. Never clobbers an existing file
37
+ (preserves user edits). Returns the path to the project file.
38
+ """
39
+ plot_settings_path = Path(project_path) / "plot_settings.yaml"
40
+ if not plot_settings_path.exists():
41
+ from flextool._resources import package_data_path
42
+ bundled = package_data_path("schemas/default_plot_settings.yaml")
43
+ shutil.copy2(bundled, plot_settings_path)
44
+ return plot_settings_path
45
+
46
+
47
+ def create_project(name: str) -> Path:
48
+ """Create a new project directory with all required subdirectories.
49
+
50
+ Returns the path to the newly created project directory.
51
+ """
52
+ projects_dir = get_projects_dir()
53
+ project_path = projects_dir / name
54
+ project_path.mkdir(parents=True, exist_ok=True)
55
+
56
+ for subdir in PROJECT_SUBDIRS:
57
+ (project_path / subdir).mkdir(exist_ok=True)
58
+
59
+ # Seed the per-project plot color template from the bundled default so
60
+ # the project owns an editable copy.
61
+ seed_plot_settings(project_path)
62
+
63
+ return project_path
64
+
65
+
66
+ def list_projects() -> list[str]:
67
+ """Return a sorted list of project directory names.
68
+
69
+ Returns an empty list if the projects directory does not exist.
70
+ """
71
+ projects_dir = get_projects_dir()
72
+ if not projects_dir.exists():
73
+ return []
74
+ return sorted(
75
+ entry.name
76
+ for entry in projects_dir.iterdir()
77
+ if entry.is_dir()
78
+ )
79
+
80
+
81
+ def rename_project(old_name: str, new_name: str) -> Path:
82
+ """Rename a project directory.
83
+
84
+ Returns the path to the renamed project directory.
85
+
86
+ Raises:
87
+ FileNotFoundError: If the source project does not exist.
88
+ FileExistsError: If a project with the new name already exists.
89
+ """
90
+ projects_dir = get_projects_dir()
91
+ old_path = projects_dir / old_name
92
+ new_path = projects_dir / new_name
93
+
94
+ if not old_path.exists():
95
+ raise FileNotFoundError(f"Project '{old_name}' does not exist.")
96
+ if new_path.exists():
97
+ raise FileExistsError(f"Project '{new_name}' already exists.")
98
+
99
+ old_path.rename(new_path)
100
+ return new_path