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,215 @@
1
+ """NetworkGraphBuilder -- reads Spine DB entities and renders a network graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from matplotlib.figure import Figure
9
+
10
+ try:
11
+ import networkx as nx
12
+ except ImportError:
13
+ nx = None # type: ignore
14
+
15
+ try:
16
+ import spinedb_api as api
17
+ except ImportError:
18
+ api = None # type: ignore
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def build_network_figure(db_url: str) -> Figure | None:
24
+ """Build a network graph *Figure* from the Spine database at *db_url*.
25
+
26
+ Returns ``None`` if no lat/lon data is available or if required
27
+ dependencies (``networkx``, ``spinedb_api``) are missing.
28
+ """
29
+ if nx is None:
30
+ logger.warning("networkx is not installed -- cannot render network graph")
31
+ return None
32
+ if api is None:
33
+ logger.warning("spinedb_api is not installed -- cannot render network graph")
34
+ return None
35
+
36
+ try:
37
+ db = api.DatabaseMapping(db_url)
38
+ except Exception:
39
+ logger.exception("Failed to open database: %s", db_url)
40
+ return None
41
+
42
+ try:
43
+ # 1. Get nodes with lat/lon
44
+ nodes: dict[str, dict[str, float | None]] = {}
45
+ for entity in db.find_entities(entity_class_name="node"):
46
+ name = entity["entity_byname"][0]
47
+ nodes[name] = {"lat": None, "lon": None}
48
+
49
+ for pv in db.find_parameter_values(entity_class_name="node"):
50
+ name = pv["entity_byname"][0]
51
+ param = pv["parameter_definition_name"]
52
+ value = api.from_database(pv["value"], pv["type"])
53
+ if param in ("latitude", "lat") and name in nodes:
54
+ nodes[name]["lat"] = float(value)
55
+ elif param in ("longitude", "lon") and name in nodes:
56
+ nodes[name]["lon"] = float(value)
57
+
58
+ # Check if we have coordinates -- abort if not
59
+ if not any(
60
+ n["lat"] is not None and n["lon"] is not None for n in nodes.values()
61
+ ):
62
+ return None
63
+
64
+ # Filter to nodes that have coordinates
65
+ positioned_nodes = {
66
+ k: v
67
+ for k, v in nodes.items()
68
+ if v["lat"] is not None and v["lon"] is not None
69
+ }
70
+
71
+ # 2. Get units
72
+ units = [
73
+ e["entity_byname"][0]
74
+ for e in db.find_entities(entity_class_name="unit")
75
+ ]
76
+
77
+ # 3. Get relationships
78
+ unit_inputs: dict[str, list[str]] = {}
79
+ for e in db.find_entities(entity_class_name="unit__inputNode"):
80
+ unit, node = e["entity_byname"]
81
+ unit_inputs.setdefault(unit, []).append(node)
82
+
83
+ unit_outputs: dict[str, list[str]] = {}
84
+ for e in db.find_entities(entity_class_name="unit__outputNode"):
85
+ unit, node = e["entity_byname"]
86
+ unit_outputs.setdefault(unit, []).append(node)
87
+
88
+ conn_endpoints: dict[str, tuple[str, str]] = {}
89
+ for e in db.find_entities(entity_class_name="connection__node__node"):
90
+ conn, n1, n2 = e["entity_byname"]
91
+ conn_endpoints[conn] = (n1, n2)
92
+
93
+ # 4. Build networkx graph and render
94
+ return _render_network(
95
+ positioned_nodes, units, unit_inputs, unit_outputs, conn_endpoints
96
+ )
97
+ except Exception:
98
+ logger.exception("Error building network graph from %s", db_url)
99
+ return None
100
+ finally:
101
+ db.close()
102
+
103
+
104
+ def _render_network(
105
+ positioned_nodes: dict[str, dict[str, Any]],
106
+ units: list[str],
107
+ unit_inputs: dict[str, list[str]],
108
+ unit_outputs: dict[str, list[str]],
109
+ conn_endpoints: dict[str, tuple[str, str]],
110
+ ) -> Figure:
111
+ """Build a networkx graph and render it to a matplotlib *Figure*.
112
+
113
+ This function is separated from the database layer so it can be
114
+ tested independently with mock data.
115
+
116
+ Parameters
117
+ ----------
118
+ positioned_nodes
119
+ ``{node_name: {"lat": float, "lon": float}}``
120
+ units
121
+ List of unit entity names.
122
+ unit_inputs
123
+ ``{unit_name: [input_node, ...]}``
124
+ unit_outputs
125
+ ``{unit_name: [output_node, ...]}``
126
+ conn_endpoints
127
+ ``{connection_name: (node1, node2)}``
128
+ """
129
+ G = nx.Graph()
130
+
131
+ # Add nodes at lat/lon positions
132
+ pos: dict[str, tuple[float, float]] = {}
133
+ for name, coords in positioned_nodes.items():
134
+ G.add_node(name, node_type="node")
135
+ pos[name] = (coords["lon"], coords["lat"]) # x=lon, y=lat
136
+
137
+ # Add units -- position at midpoint of connected nodes
138
+ for unit in units:
139
+ connected = unit_inputs.get(unit, []) + unit_outputs.get(unit, [])
140
+ connected_positioned = [n for n in connected if n in pos]
141
+ if not connected_positioned:
142
+ continue # skip units with no positioned nodes
143
+ avg_lon = sum(pos[n][0] for n in connected_positioned) / len(
144
+ connected_positioned
145
+ )
146
+ avg_lat = sum(pos[n][1] for n in connected_positioned) / len(
147
+ connected_positioned
148
+ )
149
+ G.add_node(unit, node_type="unit")
150
+ pos[unit] = (avg_lon, avg_lat)
151
+
152
+ # Add connection edges (node -- node arcs)
153
+ for conn, (n1, n2) in conn_endpoints.items():
154
+ if n1 in pos and n2 in pos:
155
+ G.add_edge(n1, n2, edge_type="connection", label=conn)
156
+
157
+ # Add unit edges (unit -- node arcs)
158
+ for unit, input_nodes in unit_inputs.items():
159
+ for node in input_nodes:
160
+ if unit in pos and node in pos:
161
+ G.add_edge(node, unit, edge_type="unit_input")
162
+
163
+ for unit, output_nodes in unit_outputs.items():
164
+ for node in output_nodes:
165
+ if unit in pos and node in pos:
166
+ G.add_edge(unit, node, edge_type="unit_output")
167
+
168
+ # Render to Figure
169
+ fig = Figure(figsize=(12, 8))
170
+ ax = fig.add_subplot(111)
171
+
172
+ # Draw nodes by type with different styles
173
+ node_nodes = [n for n, d in G.nodes(data=True) if d.get("node_type") == "node"]
174
+ unit_nodes = [n for n, d in G.nodes(data=True) if d.get("node_type") == "unit"]
175
+
176
+ nx.draw_networkx_nodes(
177
+ G, pos, nodelist=node_nodes, ax=ax,
178
+ node_color="steelblue", node_size=300, node_shape="o",
179
+ )
180
+ nx.draw_networkx_nodes(
181
+ G, pos, nodelist=unit_nodes, ax=ax,
182
+ node_color="coral", node_size=200, node_shape="s",
183
+ )
184
+
185
+ # Draw edges by type
186
+ conn_edges = [
187
+ (u, v)
188
+ for u, v, d in G.edges(data=True)
189
+ if d.get("edge_type") == "connection"
190
+ ]
191
+ unit_edges = [
192
+ (u, v)
193
+ for u, v, d in G.edges(data=True)
194
+ if d.get("edge_type") in ("unit_input", "unit_output")
195
+ ]
196
+
197
+ nx.draw_networkx_edges(
198
+ G, pos, edgelist=conn_edges, ax=ax,
199
+ width=2, edge_color="gray",
200
+ connectionstyle="arc3,rad=0.1",
201
+ arrows=True,
202
+ )
203
+ nx.draw_networkx_edges(
204
+ G, pos, edgelist=unit_edges, ax=ax,
205
+ width=1, edge_color="lightgray", style="dashed",
206
+ )
207
+
208
+ nx.draw_networkx_labels(G, pos, ax=ax, font_size=8)
209
+
210
+ ax.set_xlabel("Longitude")
211
+ ax.set_ylabel("Latitude")
212
+ ax.set_aspect("equal")
213
+ fig.tight_layout()
214
+
215
+ return fig
@@ -0,0 +1,393 @@
1
+ """Manages output generation actions (plots, Excel, CSV) as background operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Callable, TYPE_CHECKING
12
+
13
+ from flextool.gui.cli_format import format_cmd_for_log
14
+ from flextool.gui.config_parser import parse_plot_configs
15
+ from flextool.gui.data_models import PlotSettings, ProjectSettings
16
+ from flextool.gui.platform_utils import open_file_in_default_app
17
+ from flextool.gui.project_utils import get_projects_dir
18
+ from flextool.gui.scenario_key import resolve_subdir_for_read
19
+
20
+ if TYPE_CHECKING:
21
+ from flextool.gui.execution_manager import ExecutionManager
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class OutputActionManager:
27
+ """Manages output generation actions as background operations.
28
+
29
+ Each action creates an auxiliary job in the :class:`ExecutionManager` so
30
+ its output is visible in the ExecutionWindow. When the command finishes
31
+ the optional *on_complete* callback is invoked **from the worker thread**
32
+ -- the caller should use ``root.after()`` to safely update tkinter.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ project_path: Path,
38
+ settings: ProjectSettings,
39
+ execution_mgr: ExecutionManager | None = None,
40
+ on_complete: Callable[[str, bool], None] | None = None,
41
+ ) -> None:
42
+ self.project_path = project_path
43
+ self.settings = settings
44
+ self._execution_mgr = execution_mgr
45
+ self._on_complete = on_complete
46
+ self._running_actions: set[str] = set()
47
+ self._action_gen: dict[str, int] = {}
48
+ self._lock = threading.Lock()
49
+
50
+ # ------------------------------------------------------------------
51
+ # Public query
52
+ # ------------------------------------------------------------------
53
+
54
+ def is_running(self, action_name: str) -> bool:
55
+ """Return *True* if the given action is currently running."""
56
+ with self._lock:
57
+ return action_name in self._running_actions
58
+
59
+ # ------------------------------------------------------------------
60
+ # Scenario-level actions
61
+ # ------------------------------------------------------------------
62
+
63
+ def run_scenario_plots(self, scenario_ids: list[tuple[int, str]]) -> None:
64
+ action = "scen_plots"
65
+ gen = self._mark_running(action)
66
+ job = self._create_aux_job(action, "Scenario pngs")
67
+
68
+ def _work() -> bool:
69
+ ok = True
70
+ for source_number, name in scenario_ids:
71
+ cmd = self._build_write_outputs_cmd(source_number, name, ["plot"])
72
+ if not self._run_subprocess(cmd, job):
73
+ ok = False
74
+ return ok
75
+
76
+ self._start_thread(action, gen, _work, job)
77
+
78
+ def run_scenario_excel(self, scenario_ids: list[tuple[int, str]]) -> None:
79
+ action = "scen_excel"
80
+ gen = self._mark_running(action)
81
+ job = self._create_aux_job(action, "Scenario Excels")
82
+
83
+ def _work() -> bool:
84
+ ok = True
85
+ for source_number, name in scenario_ids:
86
+ cmd = self._build_write_outputs_cmd(source_number, name, ["excel"])
87
+ if not self._run_subprocess(cmd, job):
88
+ ok = False
89
+ return ok
90
+
91
+ self._start_thread(action, gen, _work, job)
92
+
93
+ def run_scenario_csvs(self, scenario_ids: list[tuple[int, str]]) -> None:
94
+ action = "scen_csvs"
95
+ gen = self._mark_running(action)
96
+ job = self._create_aux_job(action, "Scenario csvs")
97
+
98
+ def _work() -> bool:
99
+ ok = True
100
+ for source_number, name in scenario_ids:
101
+ cmd = self._build_write_outputs_cmd(source_number, name, ["csv"])
102
+ if not self._run_subprocess(cmd, job):
103
+ ok = False
104
+ return ok
105
+
106
+ self._start_thread(action, gen, _work, job)
107
+
108
+ # ------------------------------------------------------------------
109
+ # Comparison actions
110
+ # ------------------------------------------------------------------
111
+
112
+ def run_comparison_plots(self, scenario_ids: list[tuple[int, str]]) -> None:
113
+ action = "comp_plots"
114
+ gen = self._mark_running(action)
115
+ job = self._create_aux_job(action, "Comparison pngs")
116
+
117
+ def _work() -> bool:
118
+ cmd = self._build_comparison_cmd(scenario_ids, plots=True, excel=False)
119
+ ok = self._run_subprocess(cmd, job)
120
+ if ok:
121
+ comp_dir = self.project_path / "output_plot_comparisons"
122
+ first_png = self.find_first_plot(comp_dir)
123
+ if first_png is not None:
124
+ try:
125
+ open_file_in_default_app(first_png)
126
+ except OSError:
127
+ logger.warning("Could not open comparison plot: %s", first_png)
128
+ return ok
129
+
130
+ self._start_thread(action, gen, _work, job)
131
+
132
+ def run_comparison_excel(self, scenario_ids: list[tuple[int, str]]) -> None:
133
+ action = "comp_excel"
134
+ gen = self._mark_running(action)
135
+ job = self._create_aux_job(action, "Comparison Excel")
136
+
137
+ def _work() -> bool:
138
+ cmd = self._build_comparison_cmd(scenario_ids, plots=False, excel=True)
139
+ return self._run_subprocess(cmd, job)
140
+
141
+ self._start_thread(action, gen, _work, job)
142
+
143
+ # ------------------------------------------------------------------
144
+ # File finders (used by Show/Open buttons)
145
+ # ------------------------------------------------------------------
146
+
147
+ def find_first_plot(self, directory: Path) -> Path | None:
148
+ if not directory.is_dir():
149
+ return None
150
+ pngs = sorted(directory.rglob("*.png"))
151
+ return pngs[0] if pngs else None
152
+
153
+ def find_scenario_excel(self, scenario_name: str) -> Path | None:
154
+ excel_dir = self.project_path / "output_excel"
155
+ if not excel_dir.is_dir():
156
+ return None
157
+ for f in sorted(excel_dir.iterdir()):
158
+ if f.suffix.lower() == ".xlsx" and scenario_name in f.stem:
159
+ return f
160
+ return None
161
+
162
+ def find_comparison_excel(self) -> Path | None:
163
+ if not self.project_path.is_dir():
164
+ return None
165
+ xlsxs = sorted(
166
+ f for f in self.project_path.iterdir()
167
+ if f.suffix.lower() == ".xlsx" and f.stem.startswith("compare_")
168
+ )
169
+ return xlsxs[0] if xlsxs else None
170
+
171
+ # ------------------------------------------------------------------
172
+ # Config helpers
173
+ # ------------------------------------------------------------------
174
+
175
+ @staticmethod
176
+ def _resolve_active_configs(ps: PlotSettings, default_config: str) -> list[str]:
177
+ if ps.active_configs:
178
+ return ps.active_configs
179
+ if ps.config_file:
180
+ config_path = Path(ps.config_file)
181
+ if not config_path.is_absolute():
182
+ config_path = Path.cwd() / config_path
183
+ else:
184
+ # Bundled default — ``default_config`` is the historical
185
+ # repo-relative path (``templates/default_plots.yaml``);
186
+ # we resolve it against ``schemas/`` in the
187
+ # package so wheel installs work.
188
+ from flextool._resources import package_data_path
189
+ basename = Path(default_config).name
190
+ config_path = package_data_path(f"schemas/{basename}")
191
+ return parse_plot_configs(config_path) or ["default"]
192
+
193
+ # ------------------------------------------------------------------
194
+ # Command builders
195
+ # ------------------------------------------------------------------
196
+
197
+ def _build_write_outputs_cmd(
198
+ self, source_number: int, scenario_name: str, write_methods: list[str]
199
+ ) -> list[str]:
200
+ settings = self.settings
201
+ single = settings.single_plot_settings
202
+ subdir = resolve_subdir_for_read(
203
+ settings.bare_output_owners, source_number, scenario_name
204
+ )
205
+ parquet_dir = self.project_path / "output_parquet" / subdir
206
+
207
+ cmd: list[str] = [
208
+ sys.executable, "-m", "flextool.cli.cmd_write_outputs",
209
+ "--scenario-name", scenario_name,
210
+ "--read-parquet-dir", str(parquet_dir),
211
+ "--output-location", str(self.project_path),
212
+ "--subdir", subdir,
213
+ "--write-methods", *write_methods,
214
+ ]
215
+
216
+ if single.config_file:
217
+ cmd.extend(["--config-path", single.config_file])
218
+
219
+ active = self._resolve_active_configs(single, "templates/default_plots.yaml")
220
+ cmd.extend(["--active-configs", *active])
221
+
222
+ if single.duration > 0:
223
+ first_row = single.start_time
224
+ last_row = single.start_time + single.duration - 1
225
+ cmd.extend(["--plot-rows", str(first_row), str(last_row)])
226
+
227
+ if single.only_first_file:
228
+ cmd.append("--only-first-file-per-plot")
229
+
230
+ return cmd
231
+
232
+ def _build_comparison_cmd(
233
+ self,
234
+ scenario_ids: list[tuple[int, str]],
235
+ *,
236
+ plots: bool,
237
+ excel: bool,
238
+ ) -> list[str]:
239
+ settings = self.settings
240
+ comp = settings.comparison_plot_settings
241
+ parquet_base = self.project_path / "output_parquet"
242
+ plot_dir = self.project_path / "output_plot_comparisons"
243
+
244
+ subdirs = [
245
+ resolve_subdir_for_read(settings.bare_output_owners, sn, name)
246
+ for sn, name in scenario_ids
247
+ ]
248
+
249
+ cmd: list[str] = [
250
+ sys.executable, "-m", "flextool.cli.cmd_scenario_results",
251
+ "--parquet-base-dir", str(parquet_base),
252
+ "--alternatives", *subdirs,
253
+ "--plot-dir", str(plot_dir),
254
+ ]
255
+
256
+ comparison_parquet_dir = self.project_path / "output_parquet_comparison"
257
+ cmd.extend(["--comparison-parquet-dir", str(comparison_parquet_dir)])
258
+
259
+ if comp.config_file:
260
+ # Self-heal stale settings still pointing at the removed
261
+ # default_comparison_plots.yaml.
262
+ cfg_file = comp.config_file
263
+ if cfg_file.endswith("default_comparison_plots.yaml"):
264
+ cfg_file = cfg_file.replace(
265
+ "default_comparison_plots.yaml", "default_plots.yaml",
266
+ )
267
+ cmd.extend(["--output-config-path", cfg_file])
268
+
269
+ active = self._resolve_active_configs(comp, "templates/default_plots.yaml")
270
+ cmd.extend(["--active-configs", *active])
271
+
272
+ if comp.duration > 0:
273
+ first_row = comp.start_time
274
+ last_row = comp.start_time + comp.duration - 1
275
+ cmd.extend(["--plot-rows", str(first_row), str(last_row)])
276
+
277
+ if plots and comp.dispatch_plots:
278
+ cmd.append("--dispatch-plots")
279
+
280
+ # When the project debug level is on, generate per-node dispatch
281
+ # plots too (Stage 4.4). Reuse the project ``debug_level`` rather
282
+ # than inventing a new flag; ``off`` stays node-plot-free.
283
+ if plots and comp.dispatch_plots and settings.debug_level != "off":
284
+ cmd.append(f"--debug={settings.debug_level}")
285
+
286
+ if excel:
287
+ cmd.extend(["--write-to-xlsx", "--write-dispatch-xlsx"])
288
+ cmd.extend(["--excel-dir", str(self.project_path)])
289
+
290
+ if comp.only_first_file:
291
+ cmd.append("--only-first-file-per-plot")
292
+
293
+ return cmd
294
+
295
+ # ------------------------------------------------------------------
296
+ # Internal helpers
297
+ # ------------------------------------------------------------------
298
+
299
+ def _mark_running(self, action: str) -> int:
300
+ with self._lock:
301
+ self._running_actions.add(action)
302
+ gen = self._action_gen.get(action, 0) + 1
303
+ self._action_gen[action] = gen
304
+ return gen
305
+
306
+ def _mark_finished(self, action: str) -> None:
307
+ with self._lock:
308
+ self._running_actions.discard(action)
309
+
310
+ def _create_aux_job(self, action: str, title: str):
311
+ """Create an auxiliary job in the ExecutionManager (if available)."""
312
+ if self._execution_mgr is None:
313
+ return None
314
+ from flextool.gui.execution_manager import JobType
315
+ return self._execution_mgr.add_auxiliary_job(
316
+ JobType.OUTPUT_ACTION,
317
+ title,
318
+ f"output:{action}",
319
+ )
320
+
321
+ def _start_thread(self, action: str, gen: int, work, job=None) -> None:
322
+ def _wrapper() -> None:
323
+ try:
324
+ success = work()
325
+ except Exception:
326
+ logger.exception("Output action %s failed with exception", action)
327
+ success = False
328
+ finally:
329
+ self._mark_finished(action)
330
+
331
+ with self._lock:
332
+ superseded = self._action_gen.get(action, 0) != gen
333
+
334
+ if job is not None and not superseded:
335
+ self._execution_mgr.finish_job(job.job_id, success)
336
+
337
+ if self._on_complete is not None and not superseded:
338
+ try:
339
+ self._on_complete(action, success)
340
+ except Exception:
341
+ logger.exception("on_complete callback failed for %s", action)
342
+
343
+ thread = threading.Thread(target=_wrapper, daemon=True)
344
+ thread.start()
345
+
346
+ def _run_subprocess(self, cmd: list[str], job=None) -> bool:
347
+ """Run a subprocess to completion. Returns True on success."""
348
+ flextool_root = get_projects_dir().parent
349
+ cmd_str = format_cmd_for_log(cmd)
350
+
351
+ if job is not None:
352
+ self._execution_mgr.append_stdout(job.job_id, cmd_str)
353
+ self._execution_mgr.append_stdout(job.job_id, "")
354
+
355
+ try:
356
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
357
+ proc = subprocess.Popen(
358
+ cmd,
359
+ stdout=subprocess.PIPE,
360
+ stderr=subprocess.STDOUT,
361
+ text=True,
362
+ bufsize=1,
363
+ cwd=str(flextool_root),
364
+ env=env,
365
+ )
366
+
367
+ if job is not None:
368
+ with self._execution_mgr._lock:
369
+ job.process = proc
370
+
371
+ assert proc.stdout is not None
372
+ for line in proc.stdout:
373
+ stripped = line.rstrip("\n")
374
+ if job is not None:
375
+ self._execution_mgr.append_stdout(job.job_id, stripped)
376
+
377
+ return_code = proc.wait()
378
+ if return_code != 0:
379
+ msg = f"Process exited with code {return_code}"
380
+ logger.error("Output action %s", msg)
381
+ if job is not None:
382
+ self._execution_mgr.append_stdout(job.job_id, f"\n{msg}")
383
+ return False
384
+ return True
385
+ except FileNotFoundError:
386
+ msg = f"Could not find executable: {cmd[0]}"
387
+ logger.error(msg)
388
+ if job is not None:
389
+ self._execution_mgr.append_stdout(job.job_id, msg)
390
+ return False
391
+ except Exception:
392
+ logger.exception("Subprocess failed: %s", cmd_str)
393
+ return False