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.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- 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
|